1use std::fs;
2use std::io::Write;
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use chrono::Utc;
7
8pub 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
38pub fn accepted_marker_path() -> PathBuf {
40 config_dir().join("disclaimer-accepted")
41}
42
43fn 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 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
95pub 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
112pub 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 maybe_show_first_run().unwrap();
186 });
187 }
188}