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!(
75 "accepted_at={}\nversion=1\n",
76 Utc::now().to_rfc3339()
77 );
78 fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
79 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
98pub 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
115pub 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 maybe_show_first_run().unwrap();
189 });
190 }
191}