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!("accepted_at={}\nversion=1\n", Utc::now().to_rfc3339());
75    fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
76    // Showing acceptance implies the banner was seen.
77    let _ = mark_shown();
78    Ok(path)
79}
80
81fn mark_shown() -> Result<()> {
82    let path = shown_marker_path();
83    if path.is_file() {
84        return Ok(());
85    }
86    if let Some(parent) = path.parent() {
87        fs::create_dir_all(parent)
88            .with_context(|| format!("create config dir {}", parent.display()))?;
89    }
90    fs::write(&path, Utc::now().to_rfc3339())
91        .with_context(|| format!("write {}", path.display()))?;
92    Ok(())
93}
94
95/// Print the first-run warning once per machine (stderr).
96pub fn maybe_show_first_run() -> Result<()> {
97    if has_been_shown() {
98        return Ok(());
99    }
100    let mut out = std::io::stderr();
101    writeln!(out, "{FIRST_RUN_PREFIX}{FULL_DISCLAIMER}")?;
102    if !is_accepted() {
103        writeln!(
104            out,
105            "Live trading is blocked until you run:\n  schwab disclaimer accept --yes\n"
106        )?;
107    }
108    mark_shown()?;
109    Ok(())
110}
111
112/// Required before any non-dry-run trading mutation.
113pub fn require_accepted_for_live_trading() -> Result<()> {
114    if cfg!(test) {
115        return Ok(());
116    }
117    if is_accepted() {
118        return Ok(());
119    }
120    anyhow::bail!(
121        "Live trading blocked: risk disclaimer not accepted.\n\
122         Run: schwab disclaimer show\n\
123         Then: schwab disclaimer accept --yes\n\
124         (Or set SCHWAB_DISCLAIMER_ACCEPTED=1 for automation you control.)"
125    );
126}
127
128pub fn status_json() -> serde_json::Value {
129    serde_json::json!({
130        "accepted": is_accepted(),
131        "first_run_banner_shown": has_been_shown(),
132        "accepted_marker": accepted_marker_path(),
133        "accept_command": "schwab disclaimer accept --yes",
134        "show_command": "schwab disclaimer show",
135    })
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::sync::{Mutex, MutexGuard};
142
143    static TEST_LOCK: Mutex<()> = Mutex::new(());
144
145    fn test_guard() -> MutexGuard<'static, ()> {
146        TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
147    }
148
149    fn with_temp_config<F: FnOnce()>(f: F) {
150        let dir = tempfile::tempdir().unwrap();
151        std::env::set_var("SCHWAB_CONFIG_DIR", dir.path());
152        std::env::remove_var("SCHWAB_DISCLAIMER_ACCEPTED");
153        f();
154        std::env::remove_var("SCHWAB_CONFIG_DIR");
155    }
156
157    #[test]
158    fn acceptance_marker_written() {
159        let _g = test_guard();
160        with_temp_config(|| {
161            assert!(!is_accepted());
162            let path = mark_accepted().unwrap();
163            assert!(path.is_file());
164            assert!(is_accepted());
165        });
166    }
167
168    #[test]
169    fn env_override_skips_marker() {
170        let _g = test_guard();
171        with_temp_config(|| {
172            std::env::set_var("SCHWAB_DISCLAIMER_ACCEPTED", "1");
173            assert!(is_accepted());
174        });
175    }
176
177    #[test]
178    fn first_run_only_once() {
179        let _g = test_guard();
180        with_temp_config(|| {
181            assert!(!has_been_shown());
182            maybe_show_first_run().unwrap();
183            assert!(has_been_shown());
184            // Second call is a no-op (no panic).
185            maybe_show_first_run().unwrap();
186        });
187    }
188}