diff --git a/.env.example b/.env.example index 7b089ca..0d3f44a 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ KIS_APPSECRET=your_appsecret_here # KIS_ACCESS_TOKEN_EXPIRED_AT= # KIS_ACCESS_TOKEN_EXPIRES_IN= +# Runtime config file location: +# ~/.config/openstock/.env +# You can override the config root with OPENSTOCK_CONFIG_DIR. + # OpenDART 공시정보 API 키 # https://opendart.fss.or.kr/ 에서 발급 OPENDART_API_KEY=your_opendart_api_key_here diff --git a/README.md b/README.md index aa26585..69effef 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ | Key | Type | Meaning | | --- | --- | --- | -| `namespace` | string | `.openstock` 아래 최상위 디렉터리 이름입니다. 예: `universe`, `opendart`. | +| `namespace` | string | cache root 아래 최상위 디렉터리 이름입니다. 예: `universe`, `opendart`. | | `files` | number | namespace 안의 파일 수입니다. | | `bytes` | number | namespace 안의 총 파일 크기입니다. | @@ -397,7 +397,23 @@ ## Environment Variables -`.env` 파일에서 읽고, 일부 값은 명령 실행 중 갱신됩니다. +설정 파일은 기본적으로 `~/.config/openstock/.env`에서 읽고, 일부 값은 명령 실행 중 갱신됩니다. `OPENSTOCK_CONFIG_DIR`를 설정하면 이 루트 디렉터리를 바꿀 수 있습니다. 예를 들어 `OPENSTOCK_CONFIG_DIR=/tmp/openstock-test`이면 env 파일은 `/tmp/openstock-test/.env`, 캐시는 `/tmp/openstock-test/cache` 아래에 생성됩니다. + +바이너리로 빌드해 어느 디렉터리에서 실행하더라도 기본 설정/캐시 위치는 동일합니다. + +```bash +mkdir -p ~/.config/openstock +cp .env.example ~/.config/openstock/.env +``` + +이전 개발 버전에서 프로젝트 루트의 `.env`와 `.openstock`을 사용했다면 다음처럼 옮길 수 있습니다. + +```bash +mkdir -p ~/.config/openstock +cp .env ~/.config/openstock/.env +mkdir -p ~/.config/openstock/cache +cp -R .openstock/* ~/.config/openstock/cache/ +``` | Name | Required For | Direction | Description | | --- | --- | --- | --- | @@ -410,20 +426,20 @@ | `KIS_ACCOUNT` | `account status`, `order buy`, `order sell`, `order status` | read | `CANO-ACNT_PRDT_CD` 형식 계좌입니다. 예: `12345678-01`. | | `OPENDART_API_KEY` | `dart *` | read | OpenDART API 인증키입니다. | -`.env`는 `.gitignore` 대상입니다. 토큰과 계좌번호를 커밋하지 마십시오. +프로젝트 루트의 `.env`는 더 이상 런타임 기본 경로가 아닙니다. 토큰과 계좌번호를 커밋하지 마십시오. ## Cache and File IO -로컬 캐시는 `.openstock` 아래에 생성되며 `.gitignore` 대상입니다. +로컬 캐시는 기본적으로 `~/.config/openstock/cache` 아래에 생성됩니다. `OPENSTOCK_CONFIG_DIR`를 사용하면 `{OPENSTOCK_CONFIG_DIR}/cache` 아래에 생성됩니다. | Path | Writer | Reader | Retention | Description | | --- | --- | --- | --- | --- | -| `.openstock/universe/kind/latest.json` | `universe sync` | `universe status/list/chunks/validate` | protected | KIND 상장법인목록 최신 normalized stock list입니다. | -| `.openstock/universe/kind/meta.json` | `universe sync` | `universe status/*` | protected | universe cache metadata입니다. | -| `.openstock/universe/kind/YYYY-MM-DD.json` | `universe sync` | audit/manual use | max 7 files or 25MB | 날짜별 universe snapshot입니다. | -| `.openstock/opendart/corp_codes.json` | `dart sync`, `dart corp/disclosures/show` on cache miss | `dart corp/disclosures/show` | latest only | 상장사 `stock_code` to `corp_code` 매핑입니다. | -| `.openstock/opendart/corp_codes_meta.json` | `dart sync` | `dart status` | latest only | OpenDART corp code cache metadata입니다. | -| `.openstock/opendart/documents/{rcept_no}.zip` | `dart document`, `dart show` | `dart document`, `dart show` | max 100 files or 200MB | 공시서류 원본 ZIP cache입니다. | +| `~/.config/openstock/cache/universe/kind/latest.json` | `universe sync` | `universe status/list/chunks/validate` | protected | KIND 상장법인목록 최신 normalized stock list입니다. | +| `~/.config/openstock/cache/universe/kind/meta.json` | `universe sync` | `universe status/*` | protected | universe cache metadata입니다. | +| `~/.config/openstock/cache/universe/kind/YYYY-MM-DD.json` | `universe sync` | audit/manual use | max 7 files or 25MB | 날짜별 universe snapshot입니다. | +| `~/.config/openstock/cache/opendart/corp_codes.json` | `dart sync`, `dart corp/disclosures/show` on cache miss | `dart corp/disclosures/show` | latest only | 상장사 `stock_code` to `corp_code` 매핑입니다. | +| `~/.config/openstock/cache/opendart/corp_codes_meta.json` | `dart sync` | `dart status` | latest only | OpenDART corp code cache metadata입니다. | +| `~/.config/openstock/cache/opendart/documents/{rcept_no}.zip` | `dart document`, `dart show` | `dart document`, `dart show` | max 100 files or 200MB | 공시서류 원본 ZIP cache입니다. | 캐시 상태와 정리는 다음 명령으로 수행합니다. @@ -466,10 +482,10 @@ KIS 실전 API 접근 토큰을 발급하거나 유효한 기존 토큰을 재 | --- | --- | | Input | `--appkey`, `--appsecret`, `--force` | | Env read | `KIS_APPKEY`, `KIS_APPSECRET`, `KIS_ACCESS_TOKEN`, `KIS_ACCESS_TOKEN_EXPIRED_AT` | -| Env write | `KIS_APPKEY`, `KIS_APPSECRET`, `KIS_ACCESS_TOKEN`, `KIS_ACCESS_TOKEN_EXPIRED_AT`, `KIS_TOKEN_TYPE`, `KIS_ACCESS_TOKEN_EXPIRES_IN` | +| Config write | `~/.config/openstock/.env`에 `KIS_APPKEY`, `KIS_APPSECRET`, `KIS_ACCESS_TOKEN`, `KIS_ACCESS_TOKEN_EXPIRED_AT`, `KIS_TOKEN_TYPE`, `KIS_ACCESS_TOKEN_EXPIRES_IN` | | External IO | `POST https://openapi.koreainvestment.com:9443/oauth2/tokenP` only when token is missing/expired or `--force` is used | | Output fields | `broker`, `status`, `force`, `credential_source`, `token_storage`, `side_effect` | -| Side effect | writes auth state to `.env` | +| Side effect | writes auth state to `~/.config/openstock/.env` | ### `openstock api call --param KEY=VALUE` @@ -480,7 +496,7 @@ KIS endpoint를 직접 GET 호출합니다. `tr_id` 파라미터는 HTTP header | Input | `endpoint`, repeated `--param KEY=VALUE` | | Env read | `KIS_ACCESS_TOKEN`, `KIS_APPKEY`, `KIS_APPSECRET` | | External IO | KIS endpoint GET | -| File IO | `.env` read | +| File IO | `~/.config/openstock/.env` read | | Output fields | `broker`, `endpoint`, `params`, `request_semantics`, `response`, `response_semantics` | | Raw | parsed KIS response | | Side effect | endpoint에 따라 다름. typed command 사용 권장 | @@ -508,7 +524,7 @@ KIND 상장법인목록을 받아 stock universe cache를 갱신합니다. | --- | --- | | Input | `--force` | | External IO | `GET https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13` | -| File write | `.openstock/universe/kind/latest.json`, `meta.json`, `YYYY-MM-DD.json` | +| File write | `~/.config/openstock/cache/universe/kind/latest.json`, `meta.json`, `YYYY-MM-DD.json` | | Output fields | `source`, `cache_date`, `refreshed_at`, `refreshed`, `stock_count`, `counts_by_market` | | Raw | universe metadata | | Side effect | writes/updates local cache; prunes old snapshots | @@ -518,7 +534,7 @@ KIND 상장법인목록을 받아 stock universe cache를 갱신합니다. | Direction | Data | | --- | --- | | Input | 없음 | -| File read | `.openstock/universe/kind/latest.json`, `meta.json` | +| File read | `~/.config/openstock/cache/universe/kind/latest.json`, `meta.json` | | Output fields | `source`, `cache_date`, `refreshed_at`, `refreshed`, `stock_count`, `counts_by_market` | | Side effect | 없음 | @@ -592,7 +608,7 @@ OpenDART 고유번호 ZIP/XML을 받아 상장사 `stock_code` to `corp_code` ca | Input | `--force` | | Env read | `OPENDART_API_KEY` | | External IO | `GET https://opendart.fss.or.kr/api/corpCode.xml` | -| File write | `.openstock/opendart/corp_codes.json`, `corp_codes_meta.json` | +| File write | `~/.config/openstock/cache/opendart/corp_codes.json`, `corp_codes_meta.json` | | Output fields | `source`, `cache_date`, `refreshed_at`, `refreshed`, `total_count`, `listed_count` | | Side effect | writes/updates local cache | @@ -642,7 +658,7 @@ KRX 종목코드를 OpenDART 고유번호로 변환합니다. | Input | `rcept_no`: 14자리 접수번호, `--force`, `--max-chars` | | Env read | `OPENDART_API_KEY` | | External IO | `GET https://opendart.fss.or.kr/api/document.xml` | -| File read/write | `.openstock/opendart/documents/{rcept_no}.zip` | +| File read/write | `~/.config/openstock/cache/opendart/documents/{rcept_no}.zip` | | Output fields | `rcept_no`, `source`, `viewer_url`, `cached`, `zip_path`, `zip_bytes`, `files`, `text`, `text_chars`, `truncated` | | Raw | same document object | | Side effect | writes ZIP cache; prunes old document ZIP files | @@ -720,7 +736,7 @@ KIS 계좌 잔고와 보유종목을 조회합니다. | Direction | Data | | --- | --- | | Input | 없음 | -| File read | `.openstock` recursively | +| File read | `~/.config/openstock/cache` recursively | | Output fields | `root`, `exists`, `total_files`, `total_bytes`, `namespaces` | | Side effect | 없음 | @@ -731,7 +747,7 @@ KIS 계좌 잔고와 보유종목을 조회합니다. | Direction | Data | | --- | --- | | Input | `--dry-run` | -| File read | `.openstock` cache directories | +| File read | `~/.config/openstock/cache` directories | | File delete | old universe snapshots, old OpenDART document ZIPs unless `--dry-run` | | Output fields | `dry_run`, `deleted_files`, `deleted_bytes`, `reports` | | Side effect | deletes local cache files unless dry-run | diff --git a/src/apis/kis/account_config.rs b/src/apis/kis/account_config.rs index 1594c06..6e2079c 100644 --- a/src/apis/kis/account_config.rs +++ b/src/apis/kis/account_config.rs @@ -1,5 +1,4 @@ use crate::core::dotenv; -use std::path::Path; pub(crate) struct AccountConfig { pub(crate) number: String, @@ -13,7 +12,7 @@ impl AccountConfig { } pub(crate) fn read_account_config() -> Result { - let env = dotenv::read_env(Path::new(".env")); + let env = dotenv::read_env(&crate::core::paths::env_file()); if let Some(account) = env .get("KIS_ACCOUNT") @@ -38,7 +37,10 @@ pub(crate) fn read_account_config() -> Result { }); } - Err("계좌 설정이 없습니다. .env에 KIS_ACCOUNT=12345678-01 또는 KIS_CANO/KIS_ACNT_PRDT_CD를 설정하세요.".to_string()) + Err(format!( + "계좌 설정이 없습니다. {}에 KIS_ACCOUNT=12345678-01 또는 KIS_CANO/KIS_ACNT_PRDT_CD를 설정하세요.", + crate::core::paths::env_file().display() + )) } fn split_account(account: &str) -> (&str, &str) { diff --git a/src/apis/kis/call.rs b/src/apis/kis/call.rs index a393051..385fd2f 100644 --- a/src/apis/kis/call.rs +++ b/src/apis/kis/call.rs @@ -1,21 +1,20 @@ use crate::apis::kis::KisApi; use crate::core::dotenv; -use std::path::Path; const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443"; pub fn call(api: &KisApi, endpoint: &str, params: &[(&str, &str)]) -> Result { - let env = dotenv::read_env(Path::new(".env")); + let env = dotenv::read_env(&crate::core::paths::env_file()); let token = api .token() .or_else(|| env.get("KIS_ACCESS_TOKEN").map(String::as_str)) .ok_or("로그인이 필요합니다. `openstock api login`을 먼저 실행하세요.")?; let appkey = env .get("KIS_APPKEY") - .ok_or("KIS_APPKEY가 .env에 없습니다.")?; + .ok_or("KIS_APPKEY가 설정 파일에 없습니다.")?; let appsecret = env .get("KIS_APPSECRET") - .ok_or("KIS_APPSECRET가 .env에 없습니다.")?; + .ok_or("KIS_APPSECRET가 설정 파일에 없습니다.")?; let tr_id = params .iter() .find(|(key, _)| key.eq_ignore_ascii_case("tr_id")) diff --git a/src/apis/kis/login.rs b/src/apis/kis/login.rs index 13a0813..3ce6b71 100644 --- a/src/apis/kis/login.rs +++ b/src/apis/kis/login.rs @@ -1,6 +1,5 @@ use crate::core::{dotenv, LoginArguments}; use std::collections::HashMap; -use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443"; @@ -34,10 +33,10 @@ impl LoginArguments for KisLoginArguments { } } -/// KIS 실전 REST 접근토큰을 발급하고 .env에 저장한다. +/// KIS 실전 REST 접근토큰을 발급하고 설정 파일에 저장한다. pub fn login(args: &KisLoginArguments) -> Result { - let env_path = Path::new(".env"); - let env = dotenv::read_env(env_path); + let env_path = crate::core::paths::env_file(); + let env = dotenv::read_env(&env_path); if !args.force { if let Some(access_token) = valid_access_token(&env) { return Ok(access_token.to_string()); @@ -99,16 +98,16 @@ pub fn login(args: &KisLoginArguments) -> Result { .and_then(|value| value.as_u64()) .unwrap_or(0); - dotenv::write_env(env_path, "KIS_APPKEY", &args.appkey)?; - dotenv::write_env(env_path, "KIS_APPSECRET", &args.appsecret)?; - dotenv::write_env(env_path, args.token_env_key(), &access_token)?; - dotenv::write_env(env_path, "KIS_TOKEN_TYPE", token_type)?; + dotenv::write_env(&env_path, "KIS_APPKEY", &args.appkey)?; + dotenv::write_env(&env_path, "KIS_APPSECRET", &args.appsecret)?; + dotenv::write_env(&env_path, args.token_env_key(), &access_token)?; + dotenv::write_env(&env_path, "KIS_TOKEN_TYPE", token_type)?; if !expires_at.is_empty() { - dotenv::write_env(env_path, KIS_ACCESS_TOKEN_EXPIRED_AT_KEY, expires_at)?; + dotenv::write_env(&env_path, KIS_ACCESS_TOKEN_EXPIRED_AT_KEY, expires_at)?; } if expires_in > 0 { dotenv::write_env( - env_path, + &env_path, "KIS_ACCESS_TOKEN_EXPIRES_IN", &expires_in.to_string(), )?; diff --git a/src/apis/kis/order_common.rs b/src/apis/kis/order_common.rs index cbe40cb..916cfc3 100644 --- a/src/apis/kis/order_common.rs +++ b/src/apis/kis/order_common.rs @@ -2,7 +2,6 @@ use crate::apis::kis::KisApi; use crate::core::dotenv; use crate::core::TraderApi; use serde_json::json; -use std::path::Path; const KIS_BASE_URL: &str = "https://openapi.koreainvestment.com:9443"; const ORDER_CASH_ENDPOINT: &str = "/uapi/domestic-stock/v1/trading/order-cash"; @@ -72,17 +71,17 @@ pub(crate) fn order_cash( } fn post_order(api: &KisApi, tr_id: &str, body: &serde_json::Value) -> Result { - let env = dotenv::read_env(Path::new(".env")); + let env = dotenv::read_env(&crate::core::paths::env_file()); let token = api .token() .or_else(|| env.get("KIS_ACCESS_TOKEN").map(String::as_str)) .ok_or("로그인이 필요합니다. `openstock api login`을 먼저 실행하세요.")?; let appkey = env .get("KIS_APPKEY") - .ok_or("KIS_APPKEY가 .env에 없습니다.")?; + .ok_or("KIS_APPKEY가 설정 파일에 없습니다.")?; let appsecret = env .get("KIS_APPSECRET") - .ok_or("KIS_APPSECRET가 .env에 없습니다.")?; + .ok_or("KIS_APPSECRET가 설정 파일에 없습니다.")?; let url = format!("{}{}", KIS_BASE_URL, ORDER_CASH_ENDPOINT); let response = crate::core::http::agent() diff --git a/src/commands/api.rs b/src/commands/api.rs index 7051c78..620a2a4 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -2,7 +2,6 @@ use crate::apis::create_default_registry; use crate::apis::kis::{login::KisLoginArguments, KisApi}; use crate::core::TraderApi; use clap::{Args, Subcommand}; -use std::path::Path; #[derive(Subcommand)] pub enum ApiCommands { @@ -18,11 +17,11 @@ pub enum ApiCommands { #[derive(Args)] pub struct KisLoginCommand { - /// 한국투자증권 Open API 앱키 (.env의 KIS_APPKEY보다 우선) + /// 한국투자증권 Open API 앱키 (설정 파일의 KIS_APPKEY보다 우선) #[arg(long)] appkey: Option, - /// 한국투자증권 Open API 앱시크릿 (.env의 KIS_APPSECRET보다 우선) + /// 한국투자증권 Open API 앱시크릿 (설정 파일의 KIS_APPSECRET보다 우선) #[arg(long)] appsecret: Option, @@ -71,7 +70,7 @@ pub fn handle_api(sub: &ApiCommands) { ); } ApiCommands::Login(command) => { - let env = crate::core::dotenv::read_env(Path::new(".env")); + let env = crate::core::dotenv::read_env(&crate::core::paths::env_file()); let appkey = command .appkey @@ -111,10 +110,10 @@ pub fn handle_api(sub: &ApiCommands) { ), crate::core::output::field( "credential_source", - "appkey/appsecret 입력 출처. CLI 옵션이 있으면 우선 사용하고 없으면 .env의 KIS_APPKEY/KIS_APPSECRET을 사용한다.", + "appkey/appsecret 입력 출처. CLI 옵션이 있으면 우선 사용하고 없으면 설정 파일의 KIS_APPKEY/KIS_APPSECRET을 사용한다.", serde_json::json!({ - "appkey": if command.appkey.is_some() { "cli_argument" } else { ".env:KIS_APPKEY" }, - "appsecret": if command.appsecret.is_some() { "cli_argument" } else { ".env:KIS_APPSECRET" }, + "appkey": if command.appkey.is_some() { "cli_argument" } else { "config_file:KIS_APPKEY" }, + "appsecret": if command.appsecret.is_some() { "cli_argument" } else { "config_file:KIS_APPSECRET" }, }), ), crate::core::output::field( @@ -122,13 +121,14 @@ pub fn handle_api(sub: &ApiCommands) { "발급 또는 재사용된 접근토큰이 저장되는 위치와 키", serde_json::json!({ "file": ".env", + "path": crate::core::paths::env_file().display().to_string(), "access_token_key": "KIS_ACCESS_TOKEN", "expiration_key": "KIS_ACCESS_TOKEN_EXPIRED_AT", }), ), crate::core::output::field( "side_effect", - "명령의 외부 부작용. 인증 토큰과 인증 정보를 .env에 저장할 수 있지만 금융 주문은 발생하지 않는다.", + "명령의 외부 부작용. 인증 토큰과 인증 정보를 설정 파일에 저장할 수 있지만 금융 주문은 발생하지 않는다.", serde_json::json!("writes_auth_state"), ), ], @@ -234,25 +234,25 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value { "name": "KIS_APPKEY", "description": "KIS Open API application key used for authentication and every broker API request.", "required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"], - "source": ".env or api login --appkey" + "source": "~/.config/openstock/.env or api login --appkey" }, { "name": "KIS_APPSECRET", "description": "KIS Open API application secret used for authentication and every broker API request.", "required_for": ["api login", "api call", "account status", "market", "market history", "order buy", "order sell", "order status"], - "source": ".env or api login --appsecret" + "source": "~/.config/openstock/.env or api login --appsecret" }, { "name": "KIS_ACCESS_TOKEN", "description": "Bearer access token issued by api login. Read commands and order commands require it.", "required_for": ["api call", "account status", "market", "market history", "order buy", "order sell", "order status"], - "source": ".env written by api login" + "source": "~/.config/openstock/.env written by api login" }, { "name": "KIS_ACCOUNT", "description": "Live account identifier in CANO-ACNT_PRDT_CD format, for example 12345678-01. Required for account and order commands.", "required_for": ["account status", "order buy", "order sell", "order status"], - "source": ".env" + "source": "~/.config/openstock/.env" } ], "capabilities": [ @@ -260,8 +260,8 @@ fn api_catalog(api: &dyn TraderApi) -> serde_json::Value { "command": "api login", "purpose": "Prepare authentication by issuing or reusing a live KIS access token.", "inputs": [ - {"name": "--appkey", "required": false, "description": "Overrides .env KIS_APPKEY."}, - {"name": "--appsecret", "required": false, "description": "Overrides .env KIS_APPSECRET."}, + {"name": "--appkey", "required": false, "description": "Overrides configured KIS_APPKEY."}, + {"name": "--appsecret", "required": false, "description": "Overrides configured KIS_APPSECRET."}, {"name": "--force", "required": false, "description": "Issue a new token even if a valid token already exists."} ], "output_contract": "Explained JSON with broker, status, force, credential_source, token_storage, and side_effect fields.", diff --git a/src/core/cache.rs b/src/core/cache.rs index 1cc56b9..45772ad 100644 --- a/src/core/cache.rs +++ b/src/core/cache.rs @@ -4,8 +4,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::SystemTime; -const CACHE_ROOT: &str = ".openstock"; - #[derive(Debug, Clone, Serialize)] pub struct CacheStatus { pub root: String, @@ -51,10 +49,11 @@ struct SnapshotFile { } pub fn status() -> Result { - let root = Path::new(CACHE_ROOT); + let root = crate::core::paths::cache_dir(); + let root = root.as_path(); if !root.exists() { return Ok(CacheStatus { - root: CACHE_ROOT.to_string(), + root: root.display().to_string(), exists: false, total_files: 0, total_bytes: 0, @@ -74,7 +73,7 @@ pub fn status() -> Result { )?; Ok(CacheStatus { - root: CACHE_ROOT.to_string(), + root: root.display().to_string(), exists: true, total_files, total_bytes, diff --git a/src/core/dotenv.rs b/src/core/dotenv.rs index a68c76d..601c448 100644 --- a/src/core/dotenv.rs +++ b/src/core/dotenv.rs @@ -32,5 +32,8 @@ pub fn write_env(path: &Path, key: &str, value: &str) -> Result<(), String> { for (k, v) in &map { content.push_str(&format!("{}={}\n", k, v)); } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!(".env 디렉터리 생성 실패: {}", e))?; + } fs::write(path, content).map_err(|e| format!(".env 쓰기 실패: {}", e)) } diff --git a/src/core/mod.rs b/src/core/mod.rs index 842a904..4adc6b7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod cache; pub mod dotenv; pub mod http; pub mod output; +pub mod paths; pub mod registry; pub mod stock; pub mod trader; diff --git a/src/core/paths.rs b/src/core/paths.rs new file mode 100644 index 0000000..65dc3e2 --- /dev/null +++ b/src/core/paths.rs @@ -0,0 +1,52 @@ +use std::env; +use std::path::PathBuf; + +const APP_DIR: &str = "openstock"; + +pub fn config_dir() -> PathBuf { + if let Ok(value) = env::var("OPENSTOCK_CONFIG_DIR") { + let value = value.trim(); + if !value.is_empty() { + return PathBuf::from(value); + } + } + + home_dir() + .map(|home| home.join(".config").join(APP_DIR)) + .unwrap_or_else(|| PathBuf::from(".").join(".config").join(APP_DIR)) +} + +pub fn env_file() -> PathBuf { + config_dir().join(".env") +} + +pub fn cache_dir() -> PathBuf { + config_dir().join("cache") +} + +pub fn cache_namespace(path: &str) -> PathBuf { + cache_dir().join(path) +} + +fn home_dir() -> Option { + env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_file_lives_under_config_dir() { + let path = env_file(); + assert!(path.ends_with(".env")); + } + + #[test] + fn cache_dir_lives_under_config_dir() { + let path = cache_namespace("universe/kind"); + assert!(path.ends_with("cache/universe/kind")); + } +} diff --git a/src/core/trader.rs b/src/core/trader.rs index 62759d3..ccf2f24 100644 --- a/src/core/trader.rs +++ b/src/core/trader.rs @@ -17,7 +17,7 @@ impl TraderBase { /// 증권사별 로그인에 필요한 인자들의 공통 트레이트 pub trait LoginArguments { - /// .env 파일에 저장할 토큰 키 이름 (예: "KIS_ACCESS_TOKEN") + /// 설정 파일에 저장할 토큰 키 이름 (예: "KIS_ACCESS_TOKEN") fn token_env_key(&self) -> &'static str; /// 구체적인 타입으로 다운캐스팅하기 위한 Any 변환 diff --git a/src/providers/kind/universe.rs b/src/providers/kind/universe.rs index a50adca..7962f17 100644 --- a/src/providers/kind/universe.rs +++ b/src/providers/kind/universe.rs @@ -11,7 +11,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; const KIND_UNIVERSE_URL: &str = "https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13"; -const CACHE_DIR: &str = ".openstock/universe/kind"; const LATEST_FILE: &str = "latest.json"; const META_FILE: &str = "meta.json"; const SNAPSHOT_RETENTION_FILES: usize = 7; @@ -365,7 +364,7 @@ fn read_json Deserialize<'de>>(path: &Path) -> Result { } fn cache_dir() -> PathBuf { - PathBuf::from(CACHE_DIR) + crate::core::paths::cache_namespace("universe/kind") } fn counts_by_market(stocks: &[Stock]) -> BTreeMap { diff --git a/src/providers/opendart/corp_codes.rs b/src/providers/opendart/corp_codes.rs index c6304fc..5a2f56c 100644 --- a/src/providers/opendart/corp_codes.rs +++ b/src/providers/opendart/corp_codes.rs @@ -6,7 +6,6 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; const OPENDART_CORP_CODE_URL: &str = "https://opendart.fss.or.kr/api/corpCode.xml"; -const CACHE_DIR: &str = ".openstock/opendart"; const CORP_CODES_FILE: &str = "corp_codes.json"; const META_FILE: &str = "corp_codes_meta.json"; @@ -116,11 +115,16 @@ pub fn find_by_stock_code<'a>(corps: &'a [DartCorpCode], symbol: &str) -> Option } fn api_key() -> Result { - dotenv::read_env(Path::new(".env")) + dotenv::read_env(&crate::core::paths::env_file()) .get("OPENDART_API_KEY") .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string()) + .ok_or_else(|| { + format!( + "OPENDART_API_KEY가 설정 파일에 없습니다: {}", + crate::core::paths::env_file().display() + ) + }) } fn parse_corp_code_zip(bytes: &[u8]) -> Result, String> { @@ -249,7 +253,7 @@ fn read_json Deserialize<'de>>(path: &Path) -> Result { } fn cache_dir() -> PathBuf { - PathBuf::from(CACHE_DIR) + crate::core::paths::cache_namespace("opendart") } fn current_utc_date_string() -> String { diff --git a/src/providers/opendart/disclosures.rs b/src/providers/opendart/disclosures.rs index afff57e..6ff8a34 100644 --- a/src/providers/opendart/disclosures.rs +++ b/src/providers/opendart/disclosures.rs @@ -1,6 +1,5 @@ use crate::core::dotenv; use serde::{Deserialize, Serialize}; -use std::path::Path; const OPENDART_LIST_URL: &str = "https://opendart.fss.or.kr/api/list.json"; @@ -54,11 +53,16 @@ pub fn list(query: &DisclosureQuery) -> Result { } fn api_key() -> Result { - dotenv::read_env(Path::new(".env")) + dotenv::read_env(&crate::core::paths::env_file()) .get("OPENDART_API_KEY") .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string()) + .ok_or_else(|| { + format!( + "OPENDART_API_KEY가 설정 파일에 없습니다: {}", + crate::core::paths::env_file().display() + ) + }) } fn validate_query(query: &DisclosureQuery) -> Result<(), String> { diff --git a/src/providers/opendart/document.rs b/src/providers/opendart/document.rs index 1a8be5f..762bcab 100644 --- a/src/providers/opendart/document.rs +++ b/src/providers/opendart/document.rs @@ -6,7 +6,6 @@ use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; const OPENDART_DOCUMENT_URL: &str = "https://opendart.fss.or.kr/api/document.xml"; -const CACHE_DIR: &str = ".openstock/opendart/documents"; const DOCUMENT_RETENTION_FILES: usize = 100; const DOCUMENT_RETENTION_BYTES: u64 = 200 * 1024 * 1024; @@ -302,15 +301,20 @@ fn cache_path(rcept_no: &str) -> PathBuf { } fn cache_dir() -> PathBuf { - PathBuf::from(CACHE_DIR) + crate::core::paths::cache_namespace("opendart/documents") } fn api_key() -> Result { - dotenv::read_env(Path::new(".env")) + dotenv::read_env(&crate::core::paths::env_file()) .get("OPENDART_API_KEY") .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .ok_or("OPENDART_API_KEY가 .env에 없습니다.".to_string()) + .ok_or_else(|| { + format!( + "OPENDART_API_KEY가 설정 파일에 없습니다: {}", + crate::core::paths::env_file().display() + ) + }) } fn validate_rcept_no(value: &str) -> Result<(), String> {