mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 01:35:36 +00:00
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:
parent
22b6dabaeb
commit
15947de51a
238 changed files with 544 additions and 375 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
- name: Restore and save Nix store
|
||||
uses: nix-community/cache-nix-action@v7
|
||||
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: |
|
||||
nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-
|
||||
nix-v2-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
|
|
|||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
|
|
@ -13,6 +13,7 @@ on:
|
|||
paths:
|
||||
- ".github/workflows/release.yml"
|
||||
- "Cargo.toml"
|
||||
- "crates/*/Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "package.json"
|
||||
- "scripts/install.js"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ repos:
|
|||
hooks:
|
||||
# Golden files pin exact CLI output, including trailing spaces.
|
||||
- id: trailing-whitespace
|
||||
exclude: ^tests/golden/.*\.golden$
|
||||
exclude: ^crates/idx-cli/tests/golden/.*\.golden$
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^tests/golden/.*\.golden$
|
||||
exclude: ^crates/idx-cli/tests/golden/.*\.golden$
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: local
|
||||
|
|
|
|||
19
AGENTS.md
19
AGENTS.md
|
|
@ -5,15 +5,18 @@
|
|||
|
||||
## Navigation
|
||||
```text
|
||||
src/
|
||||
├── main.rs, config.rs, cache.rs, error.rs, curl_impersonate.rs
|
||||
├── cli/ clap commands: stocks, ownership, config, cache
|
||||
├── api/ providers: msn/ (default), yahoo/ (history/OHLCV)
|
||||
├── analysis/ technical, fundamental, signals
|
||||
├── ownership/ xlsx + pdf parsers, archive (ZIP/TXT), remote discovery,
|
||||
crates/idx-core/ library: all data access and analysis
|
||||
├── src/api/ providers: msn/ (default), yahoo/ (history/OHLCV)
|
||||
├── src/analysis/ technical, fundamental, signals
|
||||
├── src/ownership/ xlsx + pdf parsers, archive (ZIP/TXT), remote discovery,
|
||||
│ snapshot sync, db (SQLite/FTS5), entities, search, graph
|
||||
└── output/ table.rs, json.rs
|
||||
tests/ cli.rs (behavior), golden.rs + golden/ (output parity), fixtures/
|
||||
├── src/{cache,config,error,curl_impersonate}.rs, lib.rs (runtime)
|
||||
└── 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
|
||||
```
|
||||
|
||||
|
|
|
|||
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -718,10 +718,26 @@ dependencies = [
|
|||
"clap",
|
||||
"clap_complete",
|
||||
"comfy-table",
|
||||
"directories",
|
||||
"fastrand",
|
||||
"idx-core",
|
||||
"owo-colors",
|
||||
"predicates",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"ureq",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idx-core"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"directories",
|
||||
"fastrand",
|
||||
"quick-xml",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
|
|
|
|||
49
Cargo.toml
49
Cargo.toml
|
|
@ -1,50 +1,23 @@
|
|||
[package]
|
||||
name = "idx-cli"
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/idx-core", "crates/idx-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "CLI tool for Indonesian stock market (IDX) analysis"
|
||||
license = "MIT"
|
||||
repository = "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]]
|
||||
name = "idx"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["ownership"]
|
||||
ownership = ["dep:rusqlite", "dep:quick-xml", "dep:sha2", "dep:zip"]
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
idx-core = { path = "crates/idx-core", version = "=0.2.4" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
ureq = { version = "3", features = ["json", "cookies"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["raw_value"] }
|
||||
comfy-table = "7"
|
||||
owo-colors = "4"
|
||||
toml = "0.8"
|
||||
directories = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
fastrand = "2"
|
||||
rusqlite = { version = "0.32", features = ["bundled-full"], optional = true }
|
||||
quick-xml = { version = "0.37", optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
ureq = { version = "3", features = ["json", "cookies"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled-full"] }
|
||||
sha2 = "0.10"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
|
|
|||
43
crates/idx-cli/Cargo.toml
Normal file
43
crates/idx-cli/Cargo.toml
Normal 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
1
crates/idx-cli/LICENSE
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../LICENSE
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::error::IdxError;
|
||||
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)]
|
||||
#[command(about = "Manage local cache")]
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use clap::{Args, Subcommand};
|
||||
|
||||
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)]
|
||||
#[command(about = "Manage configuration")]
|
||||
|
|
@ -10,13 +10,13 @@ use serde::Serialize;
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::config::IdxConfig;
|
||||
use crate::error::IdxError;
|
||||
use crate::output::json;
|
||||
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,
|
||||
};
|
||||
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)]
|
||||
#[command(
|
||||
|
|
@ -215,7 +215,7 @@ pub fn handle(cmd: &OwnershipCommand, 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 db_path = db::db_path(config)?;
|
||||
let db_path = db::db_path()?;
|
||||
let result = snapshot::sync_snapshot(&manifest_source, &db_path, args.force)?;
|
||||
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
let symbol = args.symbol.trim().to_uppercase();
|
||||
let source = args.source.trim().to_lowercase();
|
||||
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.investor_type.clone().unwrap_or_else(|| "-".to_string())),
|
||||
Cell::new(match h.locality {
|
||||
Some(crate::ownership::types::Locality::Local) => "L",
|
||||
Some(crate::ownership::types::Locality::Foreign) => "F",
|
||||
Some(idx_core::ownership::types::Locality::Local) => "L",
|
||||
Some(idx_core::ownership::types::Locality::Foreign) => "F",
|
||||
None => "-",
|
||||
}),
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
let q = format!("%{}%", args.name.trim());
|
||||
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
let entities = search::fts_search(&conn, &args.query, args.limit)?;
|
||||
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
let rows = db::query_cross_holders(&conn, args.min_tickers, args.top)?;
|
||||
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
let by = args.by.trim().to_lowercase();
|
||||
if !["top1", "top3", "hhi"].contains(&by.as_str()) {
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
let symbol = args.symbol.trim().to_uppercase();
|
||||
let ticker_id = db::get_ticker_id(&conn, &symbol)?
|
||||
.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 Some(flow) = flow else {
|
||||
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}.");
|
||||
return Ok(());
|
||||
|
|
@ -595,14 +597,14 @@ fn print_flow_section(title: &str, rows: &[HolderRow]) {
|
|||
}
|
||||
|
||||
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)?;
|
||||
|
||||
if config.output.is_json() {
|
||||
#[derive(Serialize)]
|
||||
struct GraphOutput<'a> {
|
||||
nodes: &'a [crate::ownership::types::GraphNode],
|
||||
edges: &'a [crate::ownership::types::GraphEdge],
|
||||
nodes: &'a [idx_core::ownership::types::GraphNode],
|
||||
edges: &'a [idx_core::ownership::types::GraphEdge],
|
||||
}
|
||||
return json::print_json(&GraphOutput {
|
||||
nodes: &nodes,
|
||||
|
|
@ -626,7 +628,7 @@ fn handle_graph(args: &GraphArgs, 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)?;
|
||||
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
|
||||
match &args.command {
|
||||
ResolveCommand::Unresolved { limit } => {
|
||||
|
|
@ -799,7 +801,7 @@ fn handle_import(args: &ImportArgs, config: &IdxConfig) -> Result<(), IdxError>
|
|||
return Ok(());
|
||||
};
|
||||
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
|
||||
let sha256 = sha256_file(&import_input.import_path)?;
|
||||
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> {
|
||||
let conn = db::open_db(config)?;
|
||||
let conn = db::open_db()?;
|
||||
let releases = db::query_releases(&conn)?;
|
||||
|
||||
if config.output.is_json() {
|
||||
|
|
@ -1042,7 +1044,7 @@ fn cache_download_path(url: &str, format: ImportInputFormat) -> Result<PathBuf,
|
|||
ImportInputFormat::Xlsx => ".xlsx",
|
||||
_ => ".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()))?;
|
||||
|
||||
let mut file_name = url
|
||||
|
|
@ -4,27 +4,27 @@ use std::io::Read;
|
|||
use clap::{Args, Subcommand, ValueEnum};
|
||||
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::error::IdxError;
|
||||
use crate::output::{
|
||||
MacdSnapshot, TechnicalReport, VolumeSnapshot, render_compare, render_earnings,
|
||||
render_financials, render_fundamental, render_growth, render_history, render_insights,
|
||||
render_news, render_profile, render_quotes, render_risk, render_screener, render_sentiment,
|
||||
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 {
|
||||
bucket: String,
|
||||
|
|
@ -287,7 +287,7 @@ pub fn handle(
|
|||
let quote_bucket = cache_bucket(provider.kind(), "quote");
|
||||
let mut quotes = Vec::new();
|
||||
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("e_bucket, &resolved)? {
|
||||
quotes.push(q);
|
||||
continue;
|
||||
|
|
@ -343,10 +343,10 @@ pub fn handle(
|
|||
));
|
||||
}
|
||||
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());
|
||||
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,
|
||||
&format!("{resolved}-{key}"),
|
||||
)?
|
||||
|
|
@ -355,7 +355,7 @@ pub fn handle(
|
|||
}
|
||||
if offline {
|
||||
let stale = cache
|
||||
.get_offline::<Vec<crate::api::types::Ohlc>>(
|
||||
.get_offline::<Vec<idx_core::api::types::Ohlc>>(
|
||||
&history_bucket,
|
||||
&format!("{resolved}-{key}"),
|
||||
)?
|
||||
|
|
@ -379,7 +379,7 @@ pub fn handle(
|
|||
}
|
||||
Err(err) => {
|
||||
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,
|
||||
&format!("{resolved}-{key}"),
|
||||
)?
|
||||
|
|
@ -411,7 +411,7 @@ pub fn handle(
|
|||
));
|
||||
}
|
||||
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
|
||||
&& let Some(report) = cache.get::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||
{
|
||||
|
|
@ -447,7 +447,7 @@ pub fn handle(
|
|||
}
|
||||
}
|
||||
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(
|
||||
&cache,
|
||||
provider.market(),
|
||||
|
|
@ -463,7 +463,7 @@ pub fn handle(
|
|||
render_growth(&resolved, &report, &config.output, config.no_color)
|
||||
}
|
||||
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(
|
||||
&cache,
|
||||
provider.market(),
|
||||
|
|
@ -479,7 +479,7 @@ pub fn handle(
|
|||
render_valuation(&resolved, &report, &config.output, config.no_color)
|
||||
}
|
||||
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(
|
||||
&cache,
|
||||
provider.market(),
|
||||
|
|
@ -495,7 +495,7 @@ pub fn handle(
|
|||
render_risk(&resolved, &report, &config.output, config.no_color)
|
||||
}
|
||||
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(
|
||||
&cache,
|
||||
provider.market(),
|
||||
|
|
@ -511,7 +511,7 @@ pub fn handle(
|
|||
render_fundamental(&report, &config.output, config.no_color)
|
||||
}
|
||||
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 profile_provider = provider.profile_provider(&resolved)?;
|
||||
let profile: CompanyProfile = fetch_with_cache(
|
||||
|
|
@ -529,7 +529,7 @@ pub fn handle(
|
|||
render_profile(&profile, &config.output)
|
||||
}
|
||||
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 financials_provider = provider.financials_provider(&resolved)?;
|
||||
let mut financials: FinancialStatements = fetch_with_cache(
|
||||
|
|
@ -553,7 +553,7 @@ pub fn handle(
|
|||
render_financials(&filtered, &config.output)
|
||||
}
|
||||
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 earnings_provider = provider.earnings_provider(&resolved)?;
|
||||
let mut earnings: EarningsReport = fetch_with_cache(
|
||||
|
|
@ -575,7 +575,7 @@ pub fn handle(
|
|||
render_earnings(&filtered, &config.output)
|
||||
}
|
||||
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 sentiment_provider = provider.sentiment_provider(&resolved)?;
|
||||
let sentiment: SentimentData = fetch_with_cache(
|
||||
|
|
@ -593,7 +593,7 @@ pub fn handle(
|
|||
render_sentiment(&sentiment, &config.output)
|
||||
}
|
||||
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 insights_provider = provider.insights_provider(&resolved)?;
|
||||
let mut insights: InsightData = fetch_with_cache(
|
||||
|
|
@ -614,7 +614,7 @@ pub fn handle(
|
|||
render_insights(&insights, &config.output)
|
||||
}
|
||||
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 key = format!("{resolved}-{limit}");
|
||||
let news_provider = provider.news_provider(&resolved)?;
|
||||
|
|
@ -679,7 +679,7 @@ pub fn handle(
|
|||
let mut failures = Vec::new();
|
||||
|
||||
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(
|
||||
&cache,
|
||||
provider.market(),
|
||||
|
|
@ -920,7 +920,7 @@ fn filter_financial_statements(
|
|||
}
|
||||
|
||||
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()
|
||||
.filter(|row| filters.includes_period(&row.period_type))
|
||||
.cloned()
|
||||
|
|
@ -1044,11 +1044,11 @@ mod tests {
|
|||
build_technical_report, classify_earnings_period, filter_earnings_report,
|
||||
filter_financial_statements,
|
||||
};
|
||||
use crate::analysis::signals::Signal;
|
||||
use crate::api::types::{
|
||||
use idx_core::analysis::signals::Signal;
|
||||
use idx_core::api::types::{
|
||||
EarningsData, EarningsReport, FinancialStatements, InstrumentInfo, Ohlc, StatementSection,
|
||||
};
|
||||
use crate::error::IdxError;
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
#[test]
|
||||
fn technical_report_uses_latest_values() {
|
||||
|
|
@ -1080,7 +1080,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn sort_screener_top_performers_descending() {
|
||||
use crate::api::types::Quote;
|
||||
use idx_core::api::types::Quote;
|
||||
|
||||
let mut quotes = vec![
|
||||
Quote {
|
||||
|
|
@ -1137,7 +1137,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn sort_screener_worst_performers_ascending() {
|
||||
use crate::api::types::Quote;
|
||||
use idx_core::api::types::Quote;
|
||||
|
||||
let mut quotes = vec![
|
||||
Quote {
|
||||
|
|
@ -1205,8 +1205,8 @@ mod tests {
|
|||
assert_eq!(quotes.last().unwrap().symbol, "U");
|
||||
}
|
||||
|
||||
fn sample_quote(symbol: &str, change_pct: Option<f64>) -> crate::api::types::Quote {
|
||||
crate::api::types::Quote {
|
||||
fn sample_quote(symbol: &str, change_pct: Option<f64>) -> idx_core::api::types::Quote {
|
||||
idx_core::api::types::Quote {
|
||||
symbol: symbol.into(),
|
||||
price: 100,
|
||||
change: None,
|
||||
167
crates/idx-cli/src/config.rs
Normal file
167
crates/idx-cli/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +1,18 @@
|
|||
pub mod analysis;
|
||||
mod api;
|
||||
mod cache;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod curl_impersonate;
|
||||
mod error;
|
||||
mod output;
|
||||
#[cfg(feature = "ownership")]
|
||||
pub mod ownership;
|
||||
|
||||
pub mod 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 idx_core::runtime;
|
||||
|
||||
use clap::CommandFactory;
|
||||
use clap::Parser;
|
||||
use clap_complete::{generate, shells};
|
||||
|
||||
use crate::api::default_provider;
|
||||
use crate::cli::{Cli, Commands};
|
||||
use crate::config::IdxConfig;
|
||||
use crate::error::IdxError;
|
||||
use crate::output::{OutputFormat, OutputSetting, emit_error};
|
||||
use idx_core::api::default_provider;
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = run() {
|
||||
|
|
@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicU8, Ordering};
|
|||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::IdxError;
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
const PRETTY: u8 = 0;
|
||||
const COMPACT: u8 = 1;
|
||||
|
|
@ -8,13 +8,15 @@ use chrono::NaiveDate;
|
|||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
||||
use crate::analysis::signals::TechnicalSignal;
|
||||
use crate::api::types::{
|
||||
use idx_core::analysis::fundamental::{
|
||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
||||
};
|
||||
use idx_core::analysis::signals::TechnicalSignal;
|
||||
use idx_core::api::types::{
|
||||
CompanyProfile, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc, Quote,
|
||||
SentimentData,
|
||||
};
|
||||
use crate::error::IdxError;
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
use crate::output::style::Paint;
|
||||
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
||||
|
||||
use crate::analysis::fundamental::{FundamentalReport, GrowthReport, RiskReport, ValuationReport};
|
||||
use crate::analysis::signals::Signal;
|
||||
use crate::api::types::{
|
||||
use crate::output::TechnicalReport;
|
||||
use idx_core::analysis::fundamental::{
|
||||
FundamentalReport, GrowthReport, RiskReport, ValuationReport,
|
||||
};
|
||||
use idx_core::analysis::signals::Signal;
|
||||
use idx_core::api::types::{
|
||||
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, NewsItem, Ohlc,
|
||||
Quote, SentimentData,
|
||||
};
|
||||
use crate::error::IdxError;
|
||||
use crate::output::TechnicalReport;
|
||||
use idx_core::error::IdxError;
|
||||
|
||||
pub fn format_idr(value: i64) -> String {
|
||||
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> {
|
||||
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(§ion.end_date));
|
||||
let mut t = Table::new();
|
||||
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,
|
||||
humanize_metric_key, stale_quote_notes,
|
||||
};
|
||||
use crate::analysis::signals::Signal;
|
||||
use crate::api::types::Quote;
|
||||
use idx_core::analysis::signals::Signal;
|
||||
use idx_core::api::types::Quote;
|
||||
|
||||
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
||||
Quote {
|
||||
|
|
@ -26,7 +26,7 @@ fn resolve_idx_binary_path() -> PathBuf {
|
|||
}
|
||||
|
||||
let target_debug = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("../../target")
|
||||
.join("debug")
|
||||
.join(format!("idx{}", std::env::consts::EXE_SUFFIX));
|
||||
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");
|
||||
|
||||
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.
|
||||
// Pin table so tests that assert table text keep doing so; `-o` and
|
||||
// per-test IDX_OUTPUT still override this.
|
||||
|
|
@ -128,11 +129,13 @@ fn test_bin(name: &str) -> Command {
|
|||
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 {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join(name)
|
||||
core_dir().join("tests").join("fixtures").join(name)
|
||||
}
|
||||
|
||||
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",
|
||||
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",
|
||||
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]
|
||||
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");
|
||||
let page_url = spawn_single_response_server("text/html", page);
|
||||
|
||||
|
|
@ -1098,7 +1101,7 @@ fn ownership_discover_finds_xlsx_on_share_ownership_page() {
|
|||
|
||||
#[test]
|
||||
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");
|
||||
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 fake_mutool_dir = install_fake_mutool(
|
||||
&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_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 fake_mutool_dir = install_fake_mutool(
|
||||
&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_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 fake_mutool_dir = install_fake_mutool(
|
||||
&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 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");
|
||||
fs::write(
|
||||
&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");
|
||||
|
||||
|
|
@ -1495,7 +1498,7 @@ fn ownership_import_file_rejects_above5_xlsx() {
|
|||
let xlsx_path = root.join("peng-2026-09-23-00080-lima-persen.xlsx");
|
||||
fs::write(
|
||||
&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");
|
||||
|
||||
|
|
@ -1523,7 +1526,7 @@ fn ownership_import_force_reimports_existing_release() {
|
|||
let pdf_path = root.join("force.pdf");
|
||||
let fake_mutool_dir = install_fake_mutool(
|
||||
&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");
|
||||
|
||||
|
|
@ -1571,7 +1574,7 @@ fn ownership_import_url_rejects_legacy_above5_pdf_schema() {
|
|||
let data_home = root.join("data");
|
||||
let fake_mutool_dir = install_fake_mutool(
|
||||
&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_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 fake_mutool_dir = install_fake_mutool(
|
||||
&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_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");
|
||||
fs::write(
|
||||
&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");
|
||||
|
||||
|
|
@ -1654,12 +1657,12 @@ fn ownership_import_file_zip_archive_supports_releases_ticker_and_changes() {
|
|||
write_zip_with_text(
|
||||
&jan_zip,
|
||||
"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(
|
||||
&feb_zip,
|
||||
"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)
|
||||
|
|
@ -18,7 +18,7 @@ use std::sync::OnceLock;
|
|||
use serde::Deserialize;
|
||||
|
||||
const IDX: &str = env!("CARGO_BIN_EXE_idx");
|
||||
const REPO: &str = env!("CARGO_MANIFEST_DIR");
|
||||
const CRATE: &str = env!("CARGO_MANIFEST_DIR");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CaseFile {
|
||||
|
|
@ -50,8 +50,17 @@ fn default_formats() -> Vec<String> {
|
|||
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 {
|
||||
Path::new(REPO).join("tests").join("golden")
|
||||
Path::new(CRATE).join("tests").join("golden")
|
||||
}
|
||||
|
||||
fn work_dir() -> PathBuf {
|
||||
|
|
@ -70,7 +79,7 @@ fn idx_with_stdin(
|
|||
stdin: Option<&str>,
|
||||
) -> std::process::Output {
|
||||
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() {
|
||||
if key.to_string_lossy().starts_with("IDX_") || key == "NO_COLOR" {
|
||||
cmd.env_remove(key);
|
||||
|
|
@ -105,14 +114,14 @@ fn seeded_ownership_db() -> &'static Path {
|
|||
// The importer derives the release from the IDX file name.
|
||||
let xlsx = root.join("peng-2026-08-00017-satu-persen.xlsx");
|
||||
fs::copy(
|
||||
Path::new(REPO).join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"),
|
||||
core_dir().join("tests/fixtures/ksei_above1_20260831_excerpt.xlsx"),
|
||||
&xlsx,
|
||||
)
|
||||
.expect("copy xlsx fixture");
|
||||
let inputs = [
|
||||
xlsx,
|
||||
Path::new(REPO).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_20260130_excerpt.txt"),
|
||||
core_dir().join("tests/fixtures/ksei_balancepos_20260227_excerpt.txt"),
|
||||
];
|
||||
for input in inputs {
|
||||
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]| {
|
||||
String::from_utf8_lossy(bytes)
|
||||
.replace(root.to_str().unwrap(), "<ROOT>")
|
||||
.replace(REPO, "<REPO>")
|
||||
.replace(core_dir().to_str().unwrap(), "<REPO>")
|
||||
};
|
||||
format!(
|
||||
"exit: {}\n--- stdout\n{}--- stderr\n{}",
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue