Skip to main content

schwab_cli/
disclaimer.rs

1use std::fs;
2use std::io::Write;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use chrono::Utc;
7
8/// Appended to `schwab --help` / `long_about`.
9pub const HELP_DISCLAIMER: &str = "\n\
10⚠️  USE AT YOUR OWN RISK — EXPERIMENTAL SOFTWARE\n\
11This CLI can submit real trades. There is no warranty; authors are not liable for losses.\n\
12Run `schwab disclaimer show` and `schwab disclaimer accept --yes` before live trading.\n\
13Not financial advice. Not affiliated with Charles Schwab & Co., Inc.";
14
15pub const FULL_DISCLAIMER: &str = r#"SCHWAB TRADER API CLI — RISK DISCLAIMER
16
17USE AT YOUR OWN RISK. This software is EXPERIMENTAL and under active development.
18
19• Real orders: Live trading (--trust --yes, trade plans, options agent) can move money in
20  your brokerage account. Bugs, misconfiguration, API issues, and LLM errors can cause loss.
21
22• No advice: Nothing here is financial, investment, tax, or legal advice.
23
24• Your responsibility: You are solely responsible for orders, compliance, monitoring
25  agents, and securing API credentials.
26
27• No liability: Authors and contributors are not liable for damages or financial loss.
28
29• No affiliation: Not endorsed by Charles Schwab & Co., Inc.
30
31• No warranty: Provided "AS IS" under the MIT License.
32
33Before live trading: schwab disclaimer accept --yes
34"#;
35
36const FIRST_RUN_PREFIX: &str = "⚠️  FIRST RUN — READ BEFORE TRADING\n\n";
37
38/// Path to marker file recording explicit disclaimer acceptance.
39pub fn accepted_marker_path() -> PathBuf {
40    config_dir().join("disclaimer-accepted")
41}
42
43/// Path to marker file recording that the first-run banner was shown.
44fn shown_marker_path() -> PathBuf {
45    config_dir().join("disclaimer-shown")
46}
47
48fn config_dir() -> PathBuf {
49    if let Ok(dir) = std::env::var("SCHWAB_CONFIG_DIR") {
50        return PathBuf::from(dir);
51    }
52    directories::ProjectDirs::from("", "", "schwabinvestbot")
53        .map(|dirs| dirs.config_dir().to_path_buf())
54        .unwrap_or_else(|| PathBuf::from(".schwabinvestbot"))
55}
56
57pub fn is_accepted() -> bool {
58    if std::env::var_os("SCHWAB_DISCLAIMER_ACCEPTED").is_some_and(|v| !v.is_empty()) {
59        return true;
60    }
61    accepted_marker_path().is_file()
62}
63
64fn has_been_shown() -> bool {
65    shown_marker_path().is_file()
66}
67
68pub fn mark_accepted() -> Result<PathBuf> {
69    let path = accepted_marker_path();
70    if let Some(parent) = path.parent() {
71        fs::create_dir_all(parent)
72            .with_context(|| format!("create config dir {}", parent.display()))?;
73    }
74    let body = format!(
75        "accepted_at={}\nversion=1\n",
76        Utc::now().to_rfc3339()
77    );
78    fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
79    // Showing acceptance implies the banner was seen.
80    let _ = mark_shown();
81    Ok(path)
82}
83
84fn mark_shown() -> Result<()> {
85    let path = shown_marker_path();
86    if path.is_file() {
87        return Ok(());
88    }
89    if let Some(parent) = path.parent() {
90        fs::create_dir_all(parent)
91            .with_context(|| format!("create config dir {}", parent.display()))?;
92    }
93    fs::write(&path, Utc::now().to_rfc3339())
94        .with_context(|| format!("write {}", path.display()))?;
95    Ok(())
96}
97
98/// Print the first-run warning once per machine (stderr).
99pub fn maybe_show_first_run() -> Result<()> {
100    if has_been_shown() {
101        return Ok(());
102    }
103    let mut out = std::io::stderr();
104    writeln!(out, "{FIRST_RUN_PREFIX}{FULL_DISCLAIMER}")?;
105    if !is_accepted() {
106        writeln!(
107            out,
108            "Live trading is blocked until you run:\n  schwab disclaimer accept --yes\n"
109        )?;
110    }
111    mark_shown()?;
112    Ok(())
113}
114
115/// Required before any non-dry-run trading mutation.
116pub fn require_accepted_for_live_trading() -> Result<()> {
117    if cfg!(test) {
118        return Ok(());
119    }
120    if is_accepted() {
121        return Ok(());
122    }
123    anyhow::bail!(
124        "Live trading blocked: risk disclaimer not accepted.\n\
125         Run: schwab disclaimer show\n\
126         Then: schwab disclaimer accept --yes\n\
127         (Or set SCHWAB_DISCLAIMER_ACCEPTED=1 for automation you control.)"
128    );
129}
130
131pub fn status_json() -> serde_json::Value {
132    serde_json::json!({
133        "accepted": is_accepted(),
134        "first_run_banner_shown": has_been_shown(),
135        "accepted_marker": accepted_marker_path(),
136        "accept_command": "schwab disclaimer accept --yes",
137        "show_command": "schwab disclaimer show",
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::sync::{Mutex, MutexGuard};
145
146    static TEST_LOCK: Mutex<()> = Mutex::new(());
147
148    fn test_guard() -> MutexGuard<'static, ()> {
149        TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
150    }
151
152    fn with_temp_config<F: FnOnce()>(f: F) {
153        let dir = tempfile::tempdir().unwrap();
154        std::env::set_var("SCHWAB_CONFIG_DIR", dir.path());
155        std::env::remove_var("SCHWAB_DISCLAIMER_ACCEPTED");
156        f();
157        std::env::remove_var("SCHWAB_CONFIG_DIR");
158    }
159
160    #[test]
161    fn acceptance_marker_written() {
162        let _g = test_guard();
163        with_temp_config(|| {
164            assert!(!is_accepted());
165            let path = mark_accepted().unwrap();
166            assert!(path.is_file());
167            assert!(is_accepted());
168        });
169    }
170
171    #[test]
172    fn env_override_skips_marker() {
173        let _g = test_guard();
174        with_temp_config(|| {
175            std::env::set_var("SCHWAB_DISCLAIMER_ACCEPTED", "1");
176            assert!(is_accepted());
177        });
178    }
179
180    #[test]
181    fn first_run_only_once() {
182        let _g = test_guard();
183        with_temp_config(|| {
184            assert!(!has_been_shown());
185            maybe_show_first_run().unwrap();
186            assert!(has_been_shown());
187            // Second call is a no-op (no panic).
188            maybe_show_first_run().unwrap();
189        });
190    }
191}