refactor: split into idx-core library and idx-cli binary workspace

Phase 2 of docs/DESIGN.md, no behavior change:

- crates/idx-core (library): api, analysis, cache, config file handling
  and provider selection, curl_impersonate, error, ownership, runtime,
  plus the shared test fixtures. clap derives are behind an optional
  `clap` feature so the library does not require clap.
- crates/idx-cli (binary `idx`): cli/, output/, and IdxConfig (output
  format and color resolution). Package and binary names are unchanged.
- config.rs is split: file read/write and validation move to core; a
  CLI test keeps core's OUTPUT_VALUES in sync with OutputSetting.
- ownership::db::open_db/db_path drop their unused IdxConfig argument.
- One workspace version; shared dependency versions; no third-party
  version changes in Cargo.lock. LICENSE is symlinked into each crate.
- flake: workspace version, new source paths, and a per-crate package
  check (idx-cli is built from its tarball against packaged idx-core,
  since idx-core is not on crates.io yet). CI cache key, release path
  filter, and the golden whitespace-hook exclusion follow the new layout.

Parity: all 143 golden outputs match byte for byte without
regeneration; 157 core + 17 CLI unit tests (173 before plus the new
OUTPUT_VALUES sync test), 62 CLI tests pass. Live A/B against main:
16/16 stocks cases identical; ownership discover got IDX 403 on both
(exit 4 vs 1 is the phase 1 exit-code change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 09:13:46 +00:00
commit 15947de51a
238 changed files with 544 additions and 375 deletions

View file

@ -27,7 +27,7 @@ jobs:
- name: Restore and save Nix store - name: Restore and save Nix store
uses: nix-community/cache-nix-action@v7 uses: nix-community/cache-nix-action@v7
with: with:
primary-key: nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-${{ hashFiles('src/**', 'tests/**', 'scripts/**', 'install.sh', 'README.md', 'LICENSE') }} primary-key: nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-${{ hashFiles('crates/**', 'scripts/**', 'install.sh', 'README.md', 'LICENSE') }}
restore-prefixes-first-match: | restore-prefixes-first-match: |
nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}- nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-
nix-v2-${{ runner.os }}-${{ runner.arch }}- nix-v2-${{ runner.os }}-${{ runner.arch }}-

View file

@ -13,6 +13,7 @@ on:
paths: paths:
- ".github/workflows/release.yml" - ".github/workflows/release.yml"
- "Cargo.toml" - "Cargo.toml"
- "crates/*/Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- "package.json" - "package.json"
- "scripts/install.js" - "scripts/install.js"

View file

@ -3,9 +3,9 @@ repos:
hooks: hooks:
# Golden files pin exact CLI output, including trailing spaces. # Golden files pin exact CLI output, including trailing spaces.
- id: trailing-whitespace - id: trailing-whitespace
exclude: ^tests/golden/.*\.golden$ exclude: ^crates/idx-cli/tests/golden/.*\.golden$
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: ^tests/golden/.*\.golden$ exclude: ^crates/idx-cli/tests/golden/.*\.golden$
- id: check-merge-conflict - id: check-merge-conflict
- repo: local - repo: local

View file

@ -5,15 +5,18 @@
## Navigation ## Navigation
```text ```text
src/ crates/idx-core/ library: all data access and analysis
├── main.rs, config.rs, cache.rs, error.rs, curl_impersonate.rs ├── src/api/ providers: msn/ (default), yahoo/ (history/OHLCV)
├── cli/ clap commands: stocks, ownership, config, cache ├── src/analysis/ technical, fundamental, signals
├── api/ providers: msn/ (default), yahoo/ (history/OHLCV) ├── src/ownership/ xlsx + pdf parsers, archive (ZIP/TXT), remote discovery,
├── analysis/ technical, fundamental, signals
├── ownership/ xlsx + pdf parsers, archive (ZIP/TXT), remote discovery,
│ snapshot sync, db (SQLite/FTS5), entities, search, graph │ snapshot sync, db (SQLite/FTS5), entities, search, graph
└── output/ table.rs, json.rs ├── src/{cache,config,error,curl_impersonate}.rs, lib.rs (runtime)
tests/ cli.rs (behavior), golden.rs + golden/ (output parity), fixtures/ └── tests/fixtures/ shared fixtures (also read by the mock provider)
crates/idx-cli/ binary `idx`
├── src/cli/ clap commands: stocks, ownership, config, cache
├── src/config.rs IdxConfig: output format and color resolution
├── src/output/ table.rs, json.rs, style.rs
└── tests/ cli.rs (behavior), golden.rs + golden/ (output parity)
scripts/ smoke, installer, and ownership snapshot publisher scripts/ smoke, installer, and ownership snapshot publisher
``` ```

20
Cargo.lock generated
View file

@ -718,10 +718,26 @@ dependencies = [
"clap", "clap",
"clap_complete", "clap_complete",
"comfy-table", "comfy-table",
"directories", "idx-core",
"fastrand",
"owo-colors", "owo-colors",
"predicates", "predicates",
"rusqlite",
"serde",
"serde_json",
"sha2",
"toml",
"ureq",
"zip",
]
[[package]]
name = "idx-core"
version = "0.2.4"
dependencies = [
"chrono",
"clap",
"directories",
"fastrand",
"quick-xml", "quick-xml",
"rusqlite", "rusqlite",
"serde", "serde",

View file

@ -1,50 +1,23 @@
[package] [workspace]
name = "idx-cli" resolver = "3"
members = ["crates/idx-core", "crates/idx-cli"]
[workspace.package]
version = "0.2.4" version = "0.2.4"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "CLI tool for Indonesian stock market (IDX) analysis"
license = "MIT" license = "MIT"
repository = "https://github.com/0xrsydn/idx-cli" repository = "https://github.com/0xrsydn/idx-cli"
homepage = "https://github.com/0xrsydn/idx-cli" homepage = "https://github.com/0xrsydn/idx-cli"
keywords = ["idx", "stocks", "indonesia", "cli", "finance"]
categories = ["command-line-utilities", "finance"]
include = [
"/Cargo.toml",
"/Cargo.lock",
"/LICENSE",
"/README.md",
"/src/**",
"/tests/cli.rs",
"/tests/fixtures/**",
]
[[bin]] [workspace.dependencies]
name = "idx" idx-core = { path = "crates/idx-core", version = "=0.2.4" }
path = "src/main.rs"
[features]
default = ["ownership"]
ownership = ["dep:rusqlite", "dep:quick-xml", "dep:sha2", "dep:zip"]
[dependencies]
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_complete = "4"
ureq = { version = "3", features = ["json", "cookies"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] } serde_json = { version = "1", features = ["raw_value"] }
comfy-table = "7"
owo-colors = "4"
toml = "0.8" toml = "0.8"
directories = "5"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
thiserror = "2" ureq = { version = "3", features = ["json", "cookies"] }
fastrand = "2" rusqlite = { version = "0.32", features = ["bundled-full"] }
rusqlite = { version = "0.32", features = ["bundled-full"], optional = true } sha2 = "0.10"
quick-xml = { version = "0.37", optional = true } zip = { version = "2", default-features = false, features = ["deflate"] }
sha2 = { version = "0.10", optional = true }
zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"

43
crates/idx-cli/Cargo.toml Normal file
View file

@ -0,0 +1,43 @@
[package]
name = "idx-cli"
description = "CLI tool for Indonesian stock market (IDX) analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "../../README.md"
keywords = ["idx", "stocks", "indonesia", "cli", "finance"]
categories = ["command-line-utilities", "finance"]
include = ["/Cargo.toml", "/LICENSE", "/src/**"]
[[bin]]
name = "idx"
path = "src/main.rs"
[features]
default = ["ownership"]
# rusqlite/sha2/ureq back ownership import code that phase 3 moves into idx-core.
ownership = ["idx-core/ownership", "dep:rusqlite", "dep:sha2", "dep:ureq"]
[dependencies]
idx-core = { workspace = true, features = ["clap"] }
clap.workspace = true
clap_complete = "4"
serde.workspace = true
serde_json.workspace = true
comfy-table = "7"
owo-colors = "4"
toml.workspace = true
chrono.workspace = true
rusqlite = { workspace = true, optional = true }
sha2 = { workspace = true, optional = true }
ureq = { workspace = true, optional = true }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
rusqlite.workspace = true
sha2.workspace = true
zip.workspace = true

1
crates/idx-cli/LICENSE Symbolic link
View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -1,9 +1,9 @@
use clap::{Args, Subcommand}; use clap::{Args, Subcommand};
use crate::cache::Cache;
use crate::error::IdxError;
use crate::output::{OutputFormat, json}; use crate::output::{OutputFormat, json};
use crate::runtime; use idx_core::cache::Cache;
use idx_core::error::IdxError;
use idx_core::runtime;
#[derive(Debug, Args)] #[derive(Debug, Args)]
#[command(about = "Manage local cache")] #[command(about = "Manage local cache")]

View file

@ -1,7 +1,7 @@
use clap::{Args, Subcommand}; use clap::{Args, Subcommand};
use crate::config::{config_path, ensure_default_config, get_config_value, set_config_value}; use crate::config::{config_path, ensure_default_config, get_config_value, set_config_value};
use crate::error::IdxError; use idx_core::error::IdxError;
#[derive(Debug, Args)] #[derive(Debug, Args)]
#[command(about = "Manage configuration")] #[command(about = "Manage configuration")]

View file

@ -10,13 +10,13 @@ use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::config::IdxConfig; use crate::config::IdxConfig;
use crate::error::IdxError;
use crate::output::json; use crate::output::json;
use crate::output::table::format_idr; use crate::output::table::format_idr;
use crate::ownership::types::{ use idx_core::error::IdxError;
use idx_core::ownership::types::{
ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource, ChangeType, FlowSignal, HolderRow, KseiHolding, OwnershipRelease, OwnershipSource,
}; };
use crate::ownership::{archive, db, entities, graph, parser, remote, search, snapshot, xlsx}; use idx_core::ownership::{archive, db, entities, graph, parser, remote, search, snapshot, xlsx};
#[derive(Debug, Args)] #[derive(Debug, Args)]
#[command( #[command(
@ -215,7 +215,7 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError
fn handle_sync(args: &SyncArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_sync(args: &SyncArgs, config: &IdxConfig) -> Result<(), IdxError> {
let manifest_source = snapshot::resolve_manifest_source(args.manifest.as_deref())?; let manifest_source = snapshot::resolve_manifest_source(args.manifest.as_deref())?;
let db_path = db::db_path(config)?; let db_path = db::db_path()?;
let result = snapshot::sync_snapshot(&manifest_source, &db_path, args.force)?; let result = snapshot::sync_snapshot(&manifest_source, &db_path, args.force)?;
if config.output.is_json() { if config.output.is_json() {
@ -301,7 +301,7 @@ fn handle_discover(args: &DiscoverArgs, config: &IdxConfig) -> Result<(), IdxErr
} }
fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let symbol = args.symbol.trim().to_uppercase(); let symbol = args.symbol.trim().to_uppercase();
let source = args.source.trim().to_lowercase(); let source = args.source.trim().to_lowercase();
if !["all", "ksei", "bing"].contains(&source.as_str()) { if !["all", "ksei", "bing"].contains(&source.as_str()) {
@ -368,8 +368,8 @@ fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError>
Cell::new(&h.name), Cell::new(&h.name),
Cell::new(h.investor_type.clone().unwrap_or_else(|| "-".to_string())), Cell::new(h.investor_type.clone().unwrap_or_else(|| "-".to_string())),
Cell::new(match h.locality { Cell::new(match h.locality {
Some(crate::ownership::types::Locality::Local) => "L", Some(idx_core::ownership::types::Locality::Local) => "L",
Some(crate::ownership::types::Locality::Foreign) => "F", Some(idx_core::ownership::types::Locality::Foreign) => "F",
None => "-", None => "-",
}), }),
Cell::new(format_idr(h.shares)), Cell::new(format_idr(h.shares)),
@ -393,7 +393,7 @@ fn handle_ticker(args: &TickerArgs, config: &IdxConfig) -> Result<(), IdxError>
} }
fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let q = format!("%{}%", args.name.trim()); let q = format!("%{}%", args.name.trim());
let entity = conn let entity = conn
@ -436,7 +436,7 @@ fn handle_entity(args: &EntityArgs, config: &IdxConfig) -> Result<(), IdxError>
} }
fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let entities = search::fts_search(&conn, &args.query, args.limit)?; let entities = search::fts_search(&conn, &args.query, args.limit)?;
if config.output.is_json() { if config.output.is_json() {
@ -471,7 +471,7 @@ fn handle_search(args: &SearchArgs, config: &IdxConfig) -> Result<(), IdxError>
} }
fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let rows = db::query_cross_holders(&conn, args.min_tickers, args.top)?; let rows = db::query_cross_holders(&conn, args.min_tickers, args.top)?;
if config.output.is_json() { if config.output.is_json() {
@ -498,7 +498,7 @@ fn handle_cross_holders(args: &CrossHolderArgs, config: &IdxConfig) -> Result<()
} }
fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let by = args.by.trim().to_lowercase(); let by = args.by.trim().to_lowercase();
if !["top1", "top3", "hhi"].contains(&by.as_str()) { if !["top1", "top3", "hhi"].contains(&by.as_str()) {
return Err(IdxError::ParseError( return Err(IdxError::ParseError(
@ -543,7 +543,7 @@ fn handle_concentration(args: &ConcentrationArgs, config: &IdxConfig) -> Result<
} }
fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let symbol = args.symbol.trim().to_uppercase(); let symbol = args.symbol.trim().to_uppercase();
let ticker_id = db::get_ticker_id(&conn, &symbol)? let ticker_id = db::get_ticker_id(&conn, &symbol)?
.ok_or_else(|| IdxError::SymbolNotFound(symbol.clone()))?; .ok_or_else(|| IdxError::SymbolNotFound(symbol.clone()))?;
@ -551,7 +551,9 @@ fn handle_flow(args: &FlowArgs, config: &IdxConfig) -> Result<(), IdxError> {
let flow = db::query_bing_flow(&conn, ticker_id)?; let flow = db::query_bing_flow(&conn, ticker_id)?;
let Some(flow) = flow else { let Some(flow) = flow else {
if config.output.is_json() { if config.output.is_json() {
return json::print_json(&Option::<crate::ownership::types::InstitutionalFlow>::None); return json::print_json(
&Option::<idx_core::ownership::types::InstitutionalFlow>::None,
);
} }
println!("No institutional flow data available for {symbol}."); println!("No institutional flow data available for {symbol}.");
return Ok(()); return Ok(());
@ -595,14 +597,14 @@ fn print_flow_section(title: &str, rows: &[HolderRow]) {
} }
fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let (nodes, edges) = graph::query_ownership_graph(&conn, &args.root, args.depth)?; let (nodes, edges) = graph::query_ownership_graph(&conn, &args.root, args.depth)?;
if config.output.is_json() { if config.output.is_json() {
#[derive(Serialize)] #[derive(Serialize)]
struct GraphOutput<'a> { struct GraphOutput<'a> {
nodes: &'a [crate::ownership::types::GraphNode], nodes: &'a [idx_core::ownership::types::GraphNode],
edges: &'a [crate::ownership::types::GraphEdge], edges: &'a [idx_core::ownership::types::GraphEdge],
} }
return json::print_json(&GraphOutput { return json::print_json(&GraphOutput {
nodes: &nodes, nodes: &nodes,
@ -626,7 +628,7 @@ fn handle_graph(args: &GraphArgs, config: &IdxConfig) -> Result<(), IdxError> {
} }
fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let changes = db::query_changes(&conn, &args.from, &args.to)?; let changes = db::query_changes(&conn, &args.from, &args.to)?;
if config.output.is_json() { if config.output.is_json() {
@ -672,7 +674,7 @@ fn handle_changes(args: &ChangesArgs, config: &IdxConfig) -> Result<(), IdxError
} }
fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError> { fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
match &args.command { match &args.command {
ResolveCommand::Unresolved { limit } => { ResolveCommand::Unresolved { limit } => {
@ -799,7 +801,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError>
return Ok(()); return Ok(());
}; };
let conn = db::open_db(config)?; let conn = db::open_db()?;
let sha256 = sha256_file(&import_input.import_path)?; let sha256 = sha256_file(&import_input.import_path)?;
if !args.force && db::release_exists(&conn, &sha256)? { if !args.force && db::release_exists(&conn, &sha256)? {
@ -911,7 +913,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError>
} }
fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> { fn handle_releases(config: &IdxConfig) -> Result<(), IdxError> {
let conn = db::open_db(config)?; let conn = db::open_db()?;
let releases = db::query_releases(&conn)?; let releases = db::query_releases(&conn)?;
if config.output.is_json() { if config.output.is_json() {
@ -1042,7 +1044,7 @@ fn cache_download_path(url: &str, format: ImportInputFormat) -> Result<PathBuf,
ImportInputFormat::Xlsx => ".xlsx", ImportInputFormat::Xlsx => ".xlsx",
_ => ".pdf", _ => ".pdf",
}; };
let raw_dir = crate::cache::cache_dir()?.join("ownership").join("raw"); let raw_dir = idx_core::cache::cache_dir()?.join("ownership").join("raw");
fs::create_dir_all(&raw_dir).map_err(|e| IdxError::Io(e.to_string()))?; fs::create_dir_all(&raw_dir).map_err(|e| IdxError::Io(e.to_string()))?;
let mut file_name = url let mut file_name = url

View file

@ -4,27 +4,27 @@ use std::io::Read;
use clap::{Args, Subcommand, ValueEnum}; use clap::{Args, Subcommand, ValueEnum};
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use crate::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
analyze_growth, analyze_risk, analyze_valuation,
};
use crate::analysis::signals::{self, Signal, TechnicalSignal};
use crate::analysis::technical;
use crate::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
NewsItem, Ohlc, Period, Quote, SentimentData,
};
use crate::api::{MarketDataProvider, SelectedProvider, history_provider};
use crate::cache::Cache;
use crate::config::{HistoryProviderKind, IdxConfig, ProviderKind}; use crate::config::{HistoryProviderKind, IdxConfig, ProviderKind};
use crate::error::IdxError;
use crate::output::{ use crate::output::{
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings, MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings,
render_financials, render_fundamental, render_growth, render_history, render_insights, render_financials, render_fundamental, render_growth, render_history, render_insights,
render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment, render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment,
render_technical, render_valuation, render_technical, render_valuation,
}; };
use crate::runtime; use idx_core::analysis::fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
analyze_growth, analyze_risk, analyze_valuation,
};
use idx_core::analysis::signals::{self, Signal, TechnicalSignal};
use idx_core::analysis::technical;
use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
NewsItem, Ohlc, Period, Quote, SentimentData,
};
use idx_core::api::{MarketDataProvider, SelectedProvider, history_provider};
use idx_core::cache::Cache;
use idx_core::error::IdxError;
use idx_core::runtime;
struct FundamentalCacheSpec { struct FundamentalCacheSpec {
bucket: String, bucket: String,
@ -287,7 +287,7 @@ pub fn handle(
let quote_bucket = cache_bucket(provider.kind(), "quote"); let quote_bucket = cache_bucket(provider.kind(), "quote");
let mut quotes = Vec::new(); let mut quotes = Vec::new();
for sym in expand_symbols(symbols)? { for sym in expand_symbols(symbols)? {
let resolved = crate::api::resolve_symbol(&sym, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?;
if !no_cache && let Some(q) = cache.get(&quote_bucket, &resolved)? { if !no_cache && let Some(q) = cache.get(&quote_bucket, &resolved)? {
quotes.push(q); quotes.push(q);
continue; continue;
@ -343,10 +343,10 @@ pub fn handle(
)); ));
} }
let history_bucket = format!("{}-history", history_source.as_str()); let history_bucket = format!("{}-history", history_source.as_str());
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let key = format!("{}-{}", period.as_str(), interval.as_str()); let key = format!("{}-{}", period.as_str(), interval.as_str());
if !no_cache if !no_cache
&& let Some(history) = cache.get::<Vec<crate::api::types::Ohlc>>( && let Some(history) = cache.get::<Vec<idx_core::api::types::Ohlc>>(
&history_bucket, &history_bucket,
&format!("{resolved}-{key}"), &format!("{resolved}-{key}"),
)? )?
@ -355,7 +355,7 @@ pub fn handle(
} }
if offline { if offline {
let stale = cache let stale = cache
.get_offline::<Vec<crate::api::types::Ohlc>>( .get_offline::<Vec<idx_core::api::types::Ohlc>>(
&history_bucket, &history_bucket,
&format!("{resolved}-{key}"), &format!("{resolved}-{key}"),
)? )?
@ -379,7 +379,7 @@ pub fn handle(
} }
Err(err) => { Err(err) => {
if !no_cache if !no_cache
&& let Some(stale) = cache.get_stale::<Vec<crate::api::types::Ohlc>>( && let Some(stale) = cache.get_stale::<Vec<idx_core::api::types::Ohlc>>(
&history_bucket, &history_bucket,
&format!("{resolved}-{key}"), &format!("{resolved}-{key}"),
)? )?
@ -411,7 +411,7 @@ pub fn handle(
)); ));
} }
let technical_bucket = format!("{}-technical", history_source.as_str()); let technical_bucket = format!("{}-technical", history_source.as_str());
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
if !no_cache if !no_cache
&& let Some(report) = cache.get::<TechnicalReport>(&technical_bucket, &resolved)? && let Some(report) = cache.get::<TechnicalReport>(&technical_bucket, &resolved)?
{ {
@ -447,7 +447,7 @@ pub fn handle(
} }
} }
StocksSubcommand::Growth { symbol } => { StocksSubcommand::Growth { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let report: GrowthReport = fetch_fundamental_analysis_report( let report: GrowthReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider.market(), provider.market(),
@ -463,7 +463,7 @@ pub fn handle(
render_growth(&resolved, &report, &config.output, config.no_color) render_growth(&resolved, &report, &config.output, config.no_color)
} }
StocksSubcommand::Valuation { symbol } => { StocksSubcommand::Valuation { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let report: ValuationReport = fetch_fundamental_analysis_report( let report: ValuationReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider.market(), provider.market(),
@ -479,7 +479,7 @@ pub fn handle(
render_valuation(&resolved, &report, &config.output, config.no_color) render_valuation(&resolved, &report, &config.output, config.no_color)
} }
StocksSubcommand::Risk { symbol } => { StocksSubcommand::Risk { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let report: RiskReport = fetch_fundamental_analysis_report( let report: RiskReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider.market(), provider.market(),
@ -495,7 +495,7 @@ pub fn handle(
render_risk(&resolved, &report, &config.output, config.no_color) render_risk(&resolved, &report, &config.output, config.no_color)
} }
StocksSubcommand::Fundamental { symbol } => { StocksSubcommand::Fundamental { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let report: FundamentalReport = fetch_fundamental_analysis_report( let report: FundamentalReport = fetch_fundamental_analysis_report(
&cache, &cache,
provider.market(), provider.market(),
@ -511,7 +511,7 @@ pub fn handle(
render_fundamental(&report, &config.output, config.no_color) render_fundamental(&report, &config.output, config.no_color)
} }
StocksSubcommand::Profile { symbol } => { StocksSubcommand::Profile { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(provider.kind(), "profile-v2"); let bucket = cache_bucket(provider.kind(), "profile-v2");
let profile_provider = provider.profile_provider(&resolved)?; let profile_provider = provider.profile_provider(&resolved)?;
let profile: CompanyProfile = fetch_with_cache( let profile: CompanyProfile = fetch_with_cache(
@ -529,7 +529,7 @@ pub fn handle(
render_profile(&profile, &config.output) render_profile(&profile, &config.output)
} }
StocksSubcommand::Financials { symbol, filters } => { StocksSubcommand::Financials { symbol, filters } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(provider.kind(), "financials"); let bucket = cache_bucket(provider.kind(), "financials");
let financials_provider = provider.financials_provider(&resolved)?; let financials_provider = provider.financials_provider(&resolved)?;
let mut financials: FinancialStatements = fetch_with_cache( let mut financials: FinancialStatements = fetch_with_cache(
@ -553,7 +553,7 @@ pub fn handle(
render_financials(&filtered, &config.output) render_financials(&filtered, &config.output)
} }
StocksSubcommand::Earnings { symbol, filters } => { StocksSubcommand::Earnings { symbol, filters } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(provider.kind(), "earnings"); let bucket = cache_bucket(provider.kind(), "earnings");
let earnings_provider = provider.earnings_provider(&resolved)?; let earnings_provider = provider.earnings_provider(&resolved)?;
let mut earnings: EarningsReport = fetch_with_cache( let mut earnings: EarningsReport = fetch_with_cache(
@ -575,7 +575,7 @@ pub fn handle(
render_earnings(&filtered, &config.output) render_earnings(&filtered, &config.output)
} }
StocksSubcommand::Sentiment { symbol } => { StocksSubcommand::Sentiment { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(provider.kind(), "sentiment"); let bucket = cache_bucket(provider.kind(), "sentiment");
let sentiment_provider = provider.sentiment_provider(&resolved)?; let sentiment_provider = provider.sentiment_provider(&resolved)?;
let sentiment: SentimentData = fetch_with_cache( let sentiment: SentimentData = fetch_with_cache(
@ -593,7 +593,7 @@ pub fn handle(
render_sentiment(&sentiment, &config.output) render_sentiment(&sentiment, &config.output)
} }
StocksSubcommand::Insights { symbol } => { StocksSubcommand::Insights { symbol } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(provider.kind(), "insights-v2"); let bucket = cache_bucket(provider.kind(), "insights-v2");
let insights_provider = provider.insights_provider(&resolved)?; let insights_provider = provider.insights_provider(&resolved)?;
let mut insights: InsightData = fetch_with_cache( let mut insights: InsightData = fetch_with_cache(
@ -614,7 +614,7 @@ pub fn handle(
render_insights(&insights, &config.output) render_insights(&insights, &config.output)
} }
StocksSubcommand::News { symbol, limit } => { StocksSubcommand::News { symbol, limit } => {
let resolved = crate::api::resolve_symbol(symbol, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(symbol, &config.exchange)?;
let bucket = cache_bucket(provider.kind(), "news"); let bucket = cache_bucket(provider.kind(), "news");
let key = format!("{resolved}-{limit}"); let key = format!("{resolved}-{limit}");
let news_provider = provider.news_provider(&resolved)?; let news_provider = provider.news_provider(&resolved)?;
@ -679,7 +679,7 @@ pub fn handle(
let mut failures = Vec::new(); let mut failures = Vec::new();
for sym in expand_symbols(symbols)? { for sym in expand_symbols(symbols)? {
let resolved = crate::api::resolve_symbol(&sym, &config.exchange)?; let resolved = idx_core::api::resolve_symbol(&sym, &config.exchange)?;
match fetch_fundamental_analysis_report( match fetch_fundamental_analysis_report(
&cache, &cache,
provider.market(), provider.market(),
@ -920,7 +920,7 @@ fn filter_financial_statements(
} }
fn filter_earnings_report(report: &EarningsReport, filters: &EarningsFilterArgs) -> EarningsReport { fn filter_earnings_report(report: &EarningsReport, filters: &EarningsFilterArgs) -> EarningsReport {
let filter_rows = |rows: &[crate::api::types::EarningsData]| { let filter_rows = |rows: &[idx_core::api::types::EarningsData]| {
rows.iter() rows.iter()
.filter(|row| filters.includes_period(&row.period_type)) .filter(|row| filters.includes_period(&row.period_type))
.cloned() .cloned()
@ -1044,11 +1044,11 @@ mod tests {
build_technical_report, classify_earnings_period, filter_earnings_report, build_technical_report, classify_earnings_period, filter_earnings_report,
filter_financial_statements, filter_financial_statements,
}; };
use crate::analysis::signals::Signal; use idx_core::analysis::signals::Signal;
use crate::api::types::{ use idx_core::api::types::{
EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection, EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection,
}; };
use crate::error::IdxError; use idx_core::error::IdxError;
#[test] #[test]
fn technical_report_uses_latest_values() { fn technical_report_uses_latest_values() {
@ -1080,7 +1080,7 @@ mod tests {
#[test] #[test]
fn sort_screener_top_performers_descending() { fn sort_screener_top_performers_descending() {
use crate::api::types::Quote; use idx_core::api::types::Quote;
let mut quotes = vec![ let mut quotes = vec![
Quote { Quote {
@ -1137,7 +1137,7 @@ mod tests {
#[test] #[test]
fn sort_screener_worst_performers_ascending() { fn sort_screener_worst_performers_ascending() {
use crate::api::types::Quote; use idx_core::api::types::Quote;
let mut quotes = vec![ let mut quotes = vec![
Quote { Quote {
@ -1205,8 +1205,8 @@ mod tests {
assert_eq!(quotes.last().unwrap().symbol, "U"); assert_eq!(quotes.last().unwrap().symbol, "U");
} }
fn sample_quote(symbol: &str, change_pct: Option<f64>) -> crate::api::types::Quote { fn sample_quote(symbol: &str, change_pct: Option<f64>) -> idx_core::api::types::Quote {
crate::api::types::Quote { idx_core::api::types::Quote {
symbol: symbol.into(), symbol: symbol.into(),
price: 100, price: 100,
change: None, change: None,

View file

@ -0,0 +1,167 @@
use std::fs;
use std::io::IsTerminal;
use serde::Deserialize;
pub use idx_core::config::*;
use crate::cli::Cli;
use crate::output::{OutputFormat, OutputSetting};
use idx_core::error::IdxError;
#[derive(Debug, Clone)]
pub struct IdxConfig {
pub provider: ProviderKind,
pub history_provider: HistoryProviderKind,
pub exchange: String,
pub output: OutputFormat,
pub no_color: bool,
pub quote_ttl: u64,
pub fundamental_ttl: u64,
}
#[derive(Debug, Deserialize, Default)]
struct FileConfig {
general: Option<FileGeneral>,
cache: Option<FileCache>,
}
#[derive(Debug, Deserialize, Default)]
struct FileGeneral {
provider: Option<ProviderKind>,
history_provider: Option<HistoryProviderKind>,
exchange: Option<String>,
output: Option<OutputSetting>,
color: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
struct FileCache {
quote_ttl: Option<u64>,
fundamental_ttl: Option<u64>,
}
impl Default for IdxConfig {
fn default() -> Self {
Self {
provider: ProviderKind::Msn,
history_provider: HistoryProviderKind::Auto,
exchange: "JK".to_string(),
output: OutputSetting::Auto.resolve(stdout_is_terminal()),
no_color: false,
quote_ttl: 300,
fundamental_ttl: 3600,
}
}
}
impl IdxConfig {
pub fn load_with_cli(cli: &Cli) -> Result<Self, IdxError> {
let mut cfg = Self::load()?;
if let Ok(provider) = std::env::var("IDX_PROVIDER") {
cfg.provider = ProviderKind::parse(&provider)?;
}
if let Ok(history_provider) = std::env::var("IDX_HISTORY_PROVIDER") {
cfg.history_provider = HistoryProviderKind::parse(&history_provider)?;
}
if let Ok(exchange) = std::env::var("IDX_EXCHANGE") {
cfg.exchange = exchange;
}
if let Ok(output) = std::env::var("IDX_OUTPUT") {
let setting = OutputSetting::parse(&output).ok_or_else(|| {
IdxError::ConfigError(format!(
"invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'"
))
})?;
cfg.output = setting.resolve(stdout_is_terminal());
}
if let Ok(no_color) = std::env::var("IDX_NO_COLOR") {
cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true");
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_QUOTE_TTL")? {
cfg.quote_ttl = parsed;
}
if let Some(parsed) = parse_env_ttl("IDX_CACHE_FUNDAMENTAL_TTL")? {
cfg.fundamental_ttl = parsed;
}
if let Some(output) = cli.output {
cfg.output = output.resolve(stdout_is_terminal());
}
cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal();
Ok(cfg)
}
pub fn load() -> Result<Self, IdxError> {
let mut cfg = Self::default();
let path = config_path()?;
if path.exists() {
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
let parsed: FileConfig =
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
if let Some(general) = parsed.general {
if let Some(provider) = general.provider {
cfg.provider = provider;
}
if let Some(history_provider) = general.history_provider {
cfg.history_provider = history_provider;
}
if let Some(exchange) = general.exchange {
cfg.exchange = exchange;
}
if let Some(output) = general.output {
cfg.output = output.resolve(stdout_is_terminal());
}
if let Some(color) = general.color {
cfg.no_color = !color;
}
}
if let Some(cache) = parsed.cache {
if let Some(v) = cache.quote_ttl {
cfg.quote_ttl = v;
}
if let Some(v) = cache.fundamental_ttl {
cfg.fundamental_ttl = v;
}
}
}
Ok(cfg)
}
}
pub fn stdout_is_terminal() -> bool {
std::io::stdout().is_terminal()
}
#[cfg(test)]
mod tests {
use super::IdxConfig;
#[test]
fn default_values_are_sane() {
let cfg = IdxConfig::default();
assert_eq!(cfg.provider, super::ProviderKind::Msn);
assert_eq!(cfg.history_provider, super::HistoryProviderKind::Auto);
assert_eq!(cfg.exchange, "JK");
assert_eq!(cfg.quote_ttl, 300);
}
}
#[cfg(test)]
mod output_values_tests {
use clap::ValueEnum;
use crate::output::OutputSetting;
#[test]
fn core_output_values_match_cli_output_setting() {
let cli: Vec<String> = OutputSetting::value_variants()
.iter()
.filter_map(|v| v.to_possible_value())
.map(|v| v.get_name().to_string())
.collect();
assert_eq!(cli, super::OUTPUT_VALUES);
}
}

View file

@ -1,50 +1,18 @@
pub mod analysis;
mod api;
mod cache;
mod cli; mod cli;
mod config; mod config;
mod curl_impersonate;
mod error;
mod output; mod output;
#[cfg(feature = "ownership")]
pub mod ownership;
pub mod runtime { use idx_core::runtime;
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};
static QUIET: AtomicBool = AtomicBool::new(false);
pub fn set_quiet(quiet: bool) {
QUIET.store(quiet, Ordering::Relaxed);
}
pub fn is_quiet() -> bool {
QUIET.load(Ordering::Relaxed)
}
pub fn info(message: impl Display) {
if !is_quiet() {
eprintln!("info: {message}");
}
}
pub fn warn(message: impl Display) {
if !is_quiet() {
eprintln!("warning: {message}");
}
}
}
use clap::CommandFactory; use clap::CommandFactory;
use clap::Parser; use clap::Parser;
use clap_complete::{generate, shells}; use clap_complete::{generate, shells};
use crate::api::default_provider;
use crate::cli::{Cli, Commands}; use crate::cli::{Cli, Commands};
use crate::config::IdxConfig; use crate::config::IdxConfig;
use crate::error::IdxError;
use crate::output::{OutputFormat, OutputSetting, emit_error}; use crate::output::{OutputFormat, OutputSetting, emit_error};
use idx_core::api::default_provider;
use idx_core::error::IdxError;
fn main() { fn main() {
if let Err(err) = run() { if let Err(err) = run() {

View file

@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicU8, Ordering};
use serde::Serialize; use serde::Serialize;
use crate::error::IdxError; use idx_core::error::IdxError;
const PRETTY: u8 = 0; const PRETTY: u8 = 0;
const COMPACT: u8 = 1; const COMPACT: u8 = 1;

View file

@ -8,13 +8,15 @@ use chrono::NaiveDate;
use clap::ValueEnum; use clap::ValueEnum;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport}; use idx_core::analysis::fundamental::{
use crate::analysis::signals::TechnicalSignal; FundamentalReport, GrowthReport, RiskReport, ValuationReport,
use crate::api::types::{ };
use idx_core::analysis::signals::TechnicalSignal;
use idx_core::api::types::{
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote, CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
SentimentData, SentimentData,
}; };
use crate::error::IdxError; use idx_core::error::IdxError;
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file). /// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)] #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]

View file

@ -1,14 +1,16 @@
use crate::output::style::Paint; use crate::output::style::Paint;
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL}; use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport}; use crate::output::TechnicalReport;
use crate::analysis::signals::Signal; use idx_core::analysis::fundamental::{
use crate::api::types::{ FundamentalReport, GrowthReport, RiskReport, ValuationReport,
};
use idx_core::analysis::signals::Signal;
use idx_core::api::types::{
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,
Quote, SentimentData, Quote, SentimentData,
}; };
use crate::error::IdxError; use idx_core::error::IdxError;
use crate::output::TechnicalReport;
pub fn format_idr(value: i64) -> String { pub fn format_idr(value: i64) -> String {
let sign = if value.is_negative() { "-" } else { "" }; let sign = if value.is_negative() { "-" } else { "" };
@ -708,7 +710,7 @@ pub fn print_profile(profile: &CompanyProfile) -> Result<(), IdxError> {
} }
pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> { pub fn print_financials(fin: &FinancialStatements) -> Result<(), IdxError> {
let print_section = |label: &str, section: &crate::api::types::StatementSection| { let print_section = |label: &str, section: &idx_core::api::types::StatementSection| {
println!("\n── {label} ({}) ──", format_table_date(&section.end_date)); println!("\n── {label} ({}) ──", format_table_date(&section.end_date));
let mut t = Table::new(); let mut t = Table::new();
let value_header = format!("VALUE ({})", section.currency); let value_header = format!("VALUE ({})", section.currency);
@ -887,8 +889,8 @@ mod tests {
format_earnings_period, format_idr, format_signal, format_table_date, format_u64, format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
humanize_metric_key, stale_quote_notes, humanize_metric_key, stale_quote_notes,
}; };
use crate::analysis::signals::Signal; use idx_core::analysis::signals::Signal;
use crate::api::types::Quote; use idx_core::api::types::Quote;
fn quote_as_of(symbol: &str, as_of: &str) -> Quote { fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
Quote { Quote {

View file

@ -26,7 +26,7 @@ fn resolve_idx_binary_path() -> PathBuf {
} }
let target_debug = Path::new(env!("CARGO_MANIFEST_DIR")) let target_debug = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target") .join("../../target")
.join("debug") .join("debug")
.join(format!("idx{}", std::env::consts::EXE_SUFFIX)); .join(format!("idx{}", std::env::consts::EXE_SUFFIX));
if candidate_is_idx_binary(&target_debug) { if candidate_is_idx_binary(&target_debug) {
@ -113,7 +113,8 @@ fn bin_with_root(root: &Path) -> Command {
fs::create_dir_all(&cache_home).expect("create cache dir"); fs::create_dir_all(&cache_home).expect("create cache dir");
let mut cmd = bin(); let mut cmd = bin();
cmd.current_dir(env!("CARGO_MANIFEST_DIR")); // The mock provider reads fixtures relative to the working directory.
cmd.current_dir(core_dir());
// Tests run with piped stdout, where the default (auto) format is JSON. // Tests run with piped stdout, where the default (auto) format is JSON.
// Pin table so tests that assert table text keep doing so; `-o` and // Pin table so tests that assert table text keep doing so; `-o` and
// per-test IDX_OUTPUT still override this. // per-test IDX_OUTPUT still override this.
@ -128,11 +129,13 @@ fn test_bin(name: &str) -> Command {
bin_with_root(&root) bin_with_root(&root)
} }
/// idx-core crate root, which owns the shared test fixtures.
fn core_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../idx-core")
}
fn fixture_path(name: &str) -> PathBuf { fn fixture_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")) core_dir().join("tests").join("fixtures").join(name)
.join("tests")
.join("fixtures")
.join(name)
} }
fn run_success_stdout(cmd: &mut Command) -> String { fn run_success_stdout(cmd: &mut Command) -> String {
@ -375,11 +378,11 @@ fn prepare_snapshot_fixture(root: &Path) -> (PathBuf, PathBuf) {
&[ &[
( (
"release-2026-01-31.pdf", "release-2026-01-31.pdf",
include_str!("fixtures/ksei_above1_stext_excerpt_prev.xml"), include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt_prev.xml"),
), ),
( (
"release-2026-02-27.pdf", "release-2026-02-27.pdf",
include_str!("fixtures/ksei_above1_stext_excerpt.xml"), include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
), ),
], ],
); );
@ -1064,7 +1067,7 @@ const UNREACHABLE_DATA_PAGE_URL: &str = "http://127.0.0.1:9/data-kepemilikan-sah
#[test] #[test]
fn ownership_discover_finds_xlsx_on_share_ownership_page() { fn ownership_discover_finds_xlsx_on_share_ownership_page() {
let page = fs::read_to_string("tests/fixtures/idx_share_ownership_page_excerpt.html") let page = fs::read_to_string(fixture_path("idx_share_ownership_page_excerpt.html"))
.expect("read share ownership page fixture"); .expect("read share ownership page fixture");
let page_url = spawn_single_response_server("text/html", page); let page_url = spawn_single_response_server("text/html", page);
@ -1098,7 +1101,7 @@ fn ownership_discover_finds_xlsx_on_share_ownership_page() {
#[test] #[test]
fn ownership_discover_lists_fixture_candidates() { fn ownership_discover_lists_fixture_candidates() {
let body = fs::read_to_string("tests/fixtures/idx_announcement_kepemilikan.json") let body = fs::read_to_string(fixture_path("idx_announcement_kepemilikan.json"))
.expect("read ownership discovery fixture"); .expect("read ownership discovery fixture");
let json_url = spawn_single_response_server("application/json", body); let json_url = spawn_single_response_server("application/json", body);
@ -1332,7 +1335,7 @@ fn ownership_import_url_supported_pdf_succeeds_with_fake_mutool() {
let db_path = root.join("ownership.db"); let db_path = root.join("ownership.db");
let fake_mutool_dir = install_fake_mutool( let fake_mutool_dir = install_fake_mutool(
&root, &root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"), include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
); );
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "supported"); let pdf_url = pdf_url(&pdf_base, "supported");
@ -1369,7 +1372,7 @@ fn ownership_import_url_caches_download_under_xdg_cache_home() {
let cache_home = root.join("xdg-cache"); let cache_home = root.join("xdg-cache");
let fake_mutool_dir = install_fake_mutool( let fake_mutool_dir = install_fake_mutool(
&root, &root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"), include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
); );
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "cached-raw"); let pdf_url = pdf_url(&pdf_base, "cached-raw");
@ -1408,7 +1411,7 @@ fn ownership_import_url_duplicate_release_is_skipped() {
let db_path = root.join("ownership.db"); let db_path = root.join("ownership.db");
let fake_mutool_dir = install_fake_mutool( let fake_mutool_dir = install_fake_mutool(
&root, &root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"), include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
); );
let first_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); let first_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let second_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); let second_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
@ -1446,7 +1449,7 @@ fn ownership_import_file_xlsx_supports_ticker_and_releases() {
let xlsx_path = root.join("peng-2026-08-00017-satu-persen.xlsx"); let xlsx_path = root.join("peng-2026-08-00017-satu-persen.xlsx");
fs::write( fs::write(
&xlsx_path, &xlsx_path,
include_bytes!("fixtures/ksei_above1_20260831_excerpt.xlsx"), include_bytes!("../../idx-core/tests/fixtures/ksei_above1_20260831_excerpt.xlsx"),
) )
.expect("write xlsx fixture"); .expect("write xlsx fixture");
@ -1495,7 +1498,7 @@ fn ownership_import_file_rejects_above5_xlsx() {
let xlsx_path = root.join("peng-2026-09-23-00080-lima-persen.xlsx"); let xlsx_path = root.join("peng-2026-09-23-00080-lima-persen.xlsx");
fs::write( fs::write(
&xlsx_path, &xlsx_path,
include_bytes!("fixtures/ksei_above5_20260923_excerpt.xlsx"), include_bytes!("../../idx-core/tests/fixtures/ksei_above5_20260923_excerpt.xlsx"),
) )
.expect("write xlsx fixture"); .expect("write xlsx fixture");
@ -1523,7 +1526,7 @@ fn ownership_import_force_reimports_existing_release() {
let pdf_path = root.join("force.pdf"); let pdf_path = root.join("force.pdf");
let fake_mutool_dir = install_fake_mutool( let fake_mutool_dir = install_fake_mutool(
&root, &root,
include_str!("fixtures/ksei_above1_stext_excerpt.xml"), include_str!("../../idx-core/tests/fixtures/ksei_above1_stext_excerpt.xml"),
); );
fs::write(&pdf_path, fake_pdf_bytes()).expect("write local pdf fixture"); fs::write(&pdf_path, fake_pdf_bytes()).expect("write local pdf fixture");
@ -1571,7 +1574,7 @@ fn ownership_import_url_rejects_legacy_above5_pdf_schema() {
let data_home = root.join("data"); let data_home = root.join("data");
let fake_mutool_dir = install_fake_mutool( let fake_mutool_dir = install_fake_mutool(
&root, &root,
include_str!("fixtures/ksei_above5_stext_excerpt.xml"), include_str!("../../idx-core/tests/fixtures/ksei_above5_stext_excerpt.xml"),
); );
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "legacy-above5"); let pdf_url = pdf_url(&pdf_base, "legacy-above5");
@ -1593,7 +1596,7 @@ fn ownership_import_url_rejects_legacy_investor_type_pdf_schema() {
let data_home = root.join("data"); let data_home = root.join("data");
let fake_mutool_dir = install_fake_mutool( let fake_mutool_dir = install_fake_mutool(
&root, &root,
include_str!("fixtures/ksei_investor_type_stext_excerpt.xml"), include_str!("../../idx-core/tests/fixtures/ksei_investor_type_stext_excerpt.xml"),
); );
let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes()); let pdf_base = spawn_single_response_server("application/pdf", fake_pdf_bytes());
let pdf_url = pdf_url(&pdf_base, "legacy-investor-type"); let pdf_url = pdf_url(&pdf_base, "legacy-investor-type");
@ -1616,7 +1619,7 @@ fn ownership_import_file_txt_archive_succeeds() {
let txt_path = root.join("Balancepos20260227.txt"); let txt_path = root.join("Balancepos20260227.txt");
fs::write( fs::write(
&txt_path, &txt_path,
include_str!("fixtures/ksei_balancepos_20260227_excerpt.txt"), include_str!("../../idx-core/tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
) )
.expect("write txt archive fixture"); .expect("write txt archive fixture");
@ -1654,12 +1657,12 @@ fn ownership_import_file_zip_archive_supports_releases_ticker_and_changes() {
write_zip_with_text( write_zip_with_text(
&jan_zip, &jan_zip,
"Balancepos20260130.txt", "Balancepos20260130.txt",
include_str!("fixtures/ksei_balancepos_20260130_excerpt.txt"), include_str!("../../idx-core/tests/fixtures/ksei_balancepos_20260130_excerpt.txt"),
); );
write_zip_with_text( write_zip_with_text(
&feb_zip, &feb_zip,
"Balancepos20260227.txt", "Balancepos20260227.txt",
include_str!("fixtures/ksei_balancepos_20260227_excerpt.txt"), include_str!("../../idx-core/tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
); );
bin_with_root(&root) bin_with_root(&root)

View file

@ -18,7 +18,7 @@ use std::sync::OnceLock;
use serde::Deserialize; use serde::Deserialize;
const IDX: &str = env!("CARGO_BIN_EXE_idx"); const IDX: &str = env!("CARGO_BIN_EXE_idx");
const REPO: &str = env!("CARGO_MANIFEST_DIR"); const CRATE: &str = env!("CARGO_MANIFEST_DIR");
#[derive(Deserialize)] #[derive(Deserialize)]
struct CaseFile { struct CaseFile {
@ -50,8 +50,17 @@ fn default_formats() -> Vec<String> {
vec!["table".into(), "json".into()] vec!["table".into(), "json".into()]
} }
/// Commands run from the idx-core crate root: the mock provider and the cases
/// read fixtures as `tests/fixtures/...` relative to the working directory.
fn core_dir() -> PathBuf {
Path::new(CRATE)
.join("../idx-core")
.canonicalize()
.expect("idx-core crate dir")
}
fn golden_dir() -> PathBuf { fn golden_dir() -> PathBuf {
Path::new(REPO).join("tests").join("golden") Path::new(CRATE).join("tests").join("golden")
} }
fn work_dir() -> PathBuf { fn work_dir() -> PathBuf {
@ -70,7 +79,7 @@ fn idx_with_stdin(
stdin: Option<&str>, stdin: Option<&str>,
) -> std::process::Output { ) -> std::process::Output {
let mut cmd = Command::new(IDX); let mut cmd = Command::new(IDX);
cmd.current_dir(REPO).args(args); cmd.current_dir(core_dir()).args(args);
for (key, _) in std::env::vars_os() { for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("IDX_") || key == "NO_COLOR" { if key.to_string_lossy().starts_with("IDX_") || key == "NO_COLOR" {
cmd.env_remove(key); cmd.env_remove(key);
@ -105,14 +114,14 @@ fn seeded_ownership_db() -> &'static Path {
// The importer derives the release from the IDX file name. // The importer derives the release from the IDX file name.
let xlsx = root.join("peng-2026-08-00017-satu-persen.xlsx"); let xlsx = root.join("peng-2026-08-00017-satu-persen.xlsx");
fs::copy( fs::copy(
Path::new(REPO).join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"), core_dir().join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"),
&xlsx, &xlsx,
) )
.expect("copy xlsx fixture"); .expect("copy xlsx fixture");
let inputs = [ let inputs = [
xlsx, xlsx,
Path::new(REPO).join("tests/fixtures/ksei_balancepos_20260130_excerpt.txt"), core_dir().join("tests/fixtures/ksei_balancepos_20260130_excerpt.txt"),
Path::new(REPO).join("tests/fixtures/ksei_balancepos_20260227_excerpt.txt"), core_dir().join("tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
]; ];
for input in inputs { for input in inputs {
let args = ["ownership", "import", "--file", input.to_str().unwrap()] let args = ["ownership", "import", "--file", input.to_str().unwrap()]
@ -137,7 +146,7 @@ fn render(root: &Path, out: &std::process::Output) -> String {
let normalize = |bytes: &[u8]| { let normalize = |bytes: &[u8]| {
String::from_utf8_lossy(bytes) String::from_utf8_lossy(bytes)
.replace(root.to_str().unwrap(), "<ROOT>") .replace(root.to_str().unwrap(), "<ROOT>")
.replace(REPO, "<REPO>") .replace(core_dir().to_str().unwrap(), "<REPO>")
}; };
format!( format!(
"exit: {}\n--- stdout\n{}--- stderr\n{}", "exit: {}\n--- stdout\n{}--- stderr\n{}",

Some files were not shown because too many files have changed in this diff Show more