diff --git a/src/cli/config.rs b/src/cli/config.rs index 13d6c54..812583a 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1,5 +1,8 @@ use clap::{Args, Subcommand}; +use crate::config::{config_path, ensure_default_config, get_config_value, set_config_value}; +use crate::error::IdxError; + #[derive(Debug, Args)] pub struct ConfigCmd { #[command(subcommand)] @@ -13,3 +16,25 @@ pub enum ConfigSubcommand { Set { key: String, value: String }, Path, } + +pub fn handle(cmd: &ConfigCmd) -> Result<(), IdxError> { + match &cmd.command { + ConfigSubcommand::Init => { + let path = ensure_default_config()?; + println!("{}", path.display()); + } + ConfigSubcommand::Get { key } => { + let value = get_config_value(key)? + .ok_or_else(|| IdxError::ConfigError(format!("key not found: {key}")))?; + println!("{value}"); + } + ConfigSubcommand::Set { key, value } => { + set_config_value(key, value)?; + println!("ok"); + } + ConfigSubcommand::Path => { + println!("{}", config_path()?.display()); + } + } + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 239ed06..5c2aacc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -16,14 +16,18 @@ pub enum Shell { #[derive(Debug, Parser)] #[command(name = "idx", about = "Indonesian stock analysis CLI")] pub struct Cli { - #[arg(short, long, value_enum, global = true, default_value_t = OutputFormat::Table)] - pub output: OutputFormat, + #[arg(short, long, value_enum, global = true)] + pub output: Option, #[arg(long, global = true)] pub no_color: bool, #[arg(short, long, global = true)] pub quiet: bool, #[arg(short, long, global = true, action = clap::ArgAction::Count)] pub verbose: u8, + #[arg(long, global = true)] + pub offline: bool, + #[arg(long, global = true)] + pub no_cache: bool, #[command(subcommand)] pub command: Commands, } diff --git a/src/config.rs b/src/config.rs index c926a28..d21bbc1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,8 +65,20 @@ impl IdxConfig { 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 Ok(v) = std::env::var("IDX_CACHE_QUOTE_TTL") + && let Ok(parsed) = v.parse::() + { + cfg.quote_ttl = parsed; + } + if let Ok(v) = std::env::var("IDX_CACHE_FUNDAMENTAL_TTL") + && let Ok(parsed) = v.parse::() + { + cfg.fundamental_ttl = parsed; + } - cfg.output = cli.output; + if let Some(output) = cli.output { + cfg.output = output; + } cfg.no_color = cfg.no_color || cli.no_color; Ok(cfg) @@ -102,12 +114,89 @@ impl IdxConfig { } } +pub fn default_config_toml() -> String { + "[general]\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string() +} + pub fn config_path() -> Result { - ProjectDirs::from("com", "idx", "idx") + ProjectDirs::from("", "", "idx") .map(|d| d.config_dir().join("config.toml")) .ok_or_else(|| IdxError::ConfigError("unable to resolve config dir".to_string())) } +pub fn ensure_default_config() -> Result { + let path = config_path()?; + if path.exists() { + return Ok(path); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| IdxError::Io(e.to_string()))?; + } + fs::write(&path, default_config_toml()).map_err(|e| IdxError::Io(e.to_string()))?; + Ok(path) +} + +pub fn get_config_value(key: &str) -> Result, IdxError> { + let path = config_path()?; + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(path).map_err(|e| IdxError::Io(e.to_string()))?; + let value: toml::Value = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?; + + let mut cur = &value; + for part in key.split('.') { + let Some(next) = cur.get(part) else { + return Ok(None); + }; + cur = next; + } + Ok(Some(cur.to_string().trim_matches('"').to_string())) +} + +pub fn set_config_value(key: &str, value: &str) -> Result<(), IdxError> { + let path = ensure_default_config()?; + let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; + let mut root: toml::Value = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?; + + let mut parts = key.split('.').peekable(); + let mut current = root + .as_table_mut() + .ok_or_else(|| IdxError::ConfigError("config root is not a table".to_string()))?; + + while let Some(part) = parts.next() { + if parts.peek().is_none() { + current.insert(part.to_string(), parse_toml_value(value)); + } else { + let entry = current + .entry(part.to_string()) + .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); + if !entry.is_table() { + *entry = toml::Value::Table(toml::map::Map::new()); + } + current = entry + .as_table_mut() + .ok_or_else(|| IdxError::ConfigError("invalid config path".to_string()))?; + } + } + + fs::write(&path, toml::to_string_pretty(&root).map_err(|e| IdxError::ConfigError(e.to_string()))?) + .map_err(|e| IdxError::Io(e.to_string())) +} + +fn parse_toml_value(value: &str) -> toml::Value { + if let Ok(v) = value.parse::() { + return toml::Value::Integer(v); + } + if let Ok(v) = value.parse::() { + return toml::Value::Float(v); + } + if let Ok(v) = value.parse::() { + return toml::Value::Boolean(v); + } + toml::Value::String(value.to_string()) +} + #[cfg(test)] mod tests { use super::IdxConfig; diff --git a/src/main.rs b/src/main.rs index fcbc1da..08482d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,13 +40,22 @@ fn run() -> Result<(), IdxError> { } Commands::Stocks(stocks) => { let provider = default_provider(); - if let Err(err) = cli::stocks::handle(stocks, &config, provider.as_ref()) { + if let Err(err) = cli::stocks::handle(stocks, &config, provider.as_ref(), cli.offline, cli.no_cache) { emit_error(&err, &config.output); return Err(err); } } - Commands::Config(_) | Commands::Cache(_) => { - println!("Not implemented yet"); + Commands::Config(cfg) => { + if let Err(err) = cli::config::handle(cfg) { + emit_error(&err, &config.output); + return Err(err); + } + } + Commands::Cache(cache) => { + if let Err(err) = cli::cache::handle(cache) { + emit_error(&err, &config.output); + return Err(err); + } } }