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
|
- 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 }}-
|
||||||
|
|
|
||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
23
AGENTS.md
23
AGENTS.md
|
|
@ -5,16 +5,19 @@
|
||||||
|
|
||||||
## 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
|
│ snapshot sync, db (SQLite/FTS5), entities, search, graph
|
||||||
├── ownership/ xlsx + pdf parsers, archive (ZIP/TXT), remote discovery,
|
├── src/{cache,config,error,curl_impersonate}.rs, lib.rs (runtime)
|
||||||
│ snapshot sync, db (SQLite/FTS5), entities, search, graph
|
└── tests/fixtures/ shared fixtures (also read by the mock provider)
|
||||||
└── output/ table.rs, json.rs
|
crates/idx-cli/ binary `idx`
|
||||||
tests/ cli.rs (behavior), golden.rs + golden/ (output parity), fixtures/
|
├── src/cli/ clap commands: stocks, ownership, config, cache
|
||||||
scripts/ smoke, installer, and ownership snapshot publisher
|
├── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Docs:
|
Docs:
|
||||||
|
|
|
||||||
20
Cargo.lock
generated
20
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
49
Cargo.toml
49
Cargo.toml
|
|
@ -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
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 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")]
|
||||||
|
|
@ -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")]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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("e_bucket, &resolved)? {
|
if !no_cache && let Some(q) = cache.get("e_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,
|
||||||
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 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() {
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)]
|
||||||
|
|
@ -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(§ion.end_date));
|
println!("\n── {label} ({}) ──", format_table_date(§ion.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 {
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue