1use std::path::{Path, PathBuf};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::core::knowledge::ProjectKnowledge;
7use crate::core::session::SessionState;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct TokenReport {
11 pub schema_version: u32,
12 pub generated_at: DateTime<Utc>,
13 pub version: String,
14 pub project_root: String,
15 pub data_dir: String,
16 pub knowledge: Option<ProjectKnowledgeSummary>,
17 pub session: Option<SessionSummary>,
18 pub cep: Option<CepSummary>,
19 pub warnings: Vec<String>,
20 pub errors: Vec<String>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ProjectKnowledgeSummary {
25 pub project_hash: String,
26 pub active_facts: usize,
27 pub archived_facts: usize,
28 pub patterns: usize,
29 pub history: usize,
30 pub updated_at: DateTime<Utc>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SessionSummary {
35 pub id: String,
36 pub started_at: DateTime<Utc>,
37 pub updated_at: DateTime<Utc>,
38 pub tool_calls: u32,
39 pub tokens_saved: u64,
40 pub tokens_input: u64,
41 pub cache_hits: u32,
42 pub files_read: u32,
43 pub commands_run: u32,
44 pub repeated_files: u32,
45 pub intents_total: u32,
46 pub intents_inferred: u32,
47 pub intents_explicit: u32,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CepSummary {
52 pub sessions: u64,
53 pub total_cache_hits: u64,
54 pub total_cache_reads: u64,
55 pub total_tokens_original: u64,
56 pub total_tokens_compressed: u64,
57 pub last_snapshot: Option<CepSnapshot>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CepSnapshot {
62 pub timestamp: String,
63 pub score: u32,
64 pub cache_hit_rate: u32,
65 pub mode_diversity: u32,
66 pub compression_rate: u32,
67 pub tool_calls: u64,
68 pub tokens_saved: u64,
69 pub complexity: String,
70}
71
72pub fn run_cli(args: &[String]) -> i32 {
73 let json = args.iter().any(|a| a == "--json");
74 let help = args.iter().any(|a| a == "--help" || a == "-h");
75 if help {
76 println!("Usage:");
77 println!(" lean-ctx token-report [--json] [--project-root <path>]");
78 return 0;
79 }
80
81 let project_root = extract_flag(args, "--project-root");
82
83 match build_report(project_root.as_deref()) {
84 Ok((report, path)) => {
85 let text = serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string());
86 let _ = crate::config_io::write_atomic_with_backup(&path, &text);
87
88 if json {
89 println!("{text}");
90 } else {
91 print_human(&report, &path);
92 }
93
94 if report.errors.is_empty() {
95 0
96 } else {
97 1
98 }
99 }
100 Err(e) => {
101 eprintln!("{e}");
102 2
103 }
104 }
105}
106
107fn build_report(project_root_override: Option<&str>) -> Result<(TokenReport, PathBuf), String> {
108 let generated_at = Utc::now();
109 let version = env!("CARGO_PKG_VERSION").to_string();
110
111 let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
112
113 let cwd = std::env::current_dir()
114 .map(|p| p.to_string_lossy().to_string())
115 .unwrap_or_else(|_| ".".to_string());
116 let project_root = project_root_override
117 .map(|s| s.to_string())
118 .unwrap_or_else(|| crate::core::protocol::detect_project_root_or_cwd(&cwd));
119
120 let mut warnings: Vec<String> = Vec::new();
121 let errors: Vec<String> = Vec::new();
122
123 let knowledge = ProjectKnowledge::load(&project_root).map(|k| ProjectKnowledgeSummary {
124 project_hash: k.project_hash.clone(),
125 active_facts: k.facts.iter().filter(|f| f.is_current()).count(),
126 archived_facts: k.facts.iter().filter(|f| !f.is_current()).count(),
127 patterns: k.patterns.len(),
128 history: k.history.len(),
129 updated_at: k.updated_at,
130 });
131
132 if knowledge.is_none() {
133 warnings.push("no project knowledge found".to_string());
134 }
135
136 let session = SessionState::load_latest().map(|s| {
137 let repeated_files = s.files_touched.iter().filter(|f| f.read_count > 1).count() as u32;
138 SessionSummary {
139 id: s.id,
140 started_at: s.started_at,
141 updated_at: s.updated_at,
142 tool_calls: s.stats.total_tool_calls,
143 tokens_saved: s.stats.total_tokens_saved,
144 tokens_input: s.stats.total_tokens_input,
145 cache_hits: s.stats.cache_hits,
146 files_read: s.stats.files_read,
147 commands_run: s.stats.commands_run,
148 repeated_files,
149 intents_total: s.intents.len() as u32,
150 intents_inferred: s.stats.intents_inferred,
151 intents_explicit: s.stats.intents_explicit,
152 }
153 });
154
155 if session.is_none() {
156 warnings.push("no active session found".to_string());
157 }
158
159 let store = crate::core::stats::load();
160 let last_snapshot = store.cep.scores.last().map(|s| CepSnapshot {
161 timestamp: s.timestamp.clone(),
162 score: s.score,
163 cache_hit_rate: s.cache_hit_rate,
164 mode_diversity: s.mode_diversity,
165 compression_rate: s.compression_rate,
166 tool_calls: s.tool_calls,
167 tokens_saved: s.tokens_saved,
168 complexity: s.complexity.clone(),
169 });
170
171 let cep = CepSummary {
172 sessions: store.cep.sessions,
173 total_cache_hits: store.cep.total_cache_hits,
174 total_cache_reads: store.cep.total_cache_reads,
175 total_tokens_original: store.cep.total_tokens_original,
176 total_tokens_compressed: store.cep.total_tokens_compressed,
177 last_snapshot,
178 };
179
180 let report_path = data_dir.join("report").join("latest.json");
181
182 let report = TokenReport {
183 schema_version: 1,
184 generated_at,
185 version,
186 project_root,
187 data_dir: data_dir.to_string_lossy().to_string(),
188 knowledge,
189 session,
190 cep: Some(cep),
191 warnings,
192 errors,
193 };
194
195 Ok((report, report_path))
196}
197
198fn print_human(report: &TokenReport, path: &Path) {
199 println!("lean-ctx token-report v{}", report.version);
200 println!(" project: {}", report.project_root);
201 println!(" data: {}", report.data_dir);
202
203 if let Some(k) = &report.knowledge {
204 println!(
205 " knowledge: {} active, {} archived, {} patterns, {} history",
206 k.active_facts, k.archived_facts, k.patterns, k.history
207 );
208 } else {
209 println!(" knowledge: (none)");
210 }
211
212 if let Some(s) = &report.session {
213 println!(
214 " session: {} calls, {} tok saved, {} files read ({} repeated), {} intents ({} inferred, {} explicit)",
215 s.tool_calls,
216 s.tokens_saved,
217 s.files_read,
218 s.repeated_files,
219 s.intents_total,
220 s.intents_inferred,
221 s.intents_explicit
222 );
223 } else {
224 println!(" session: (none)");
225 }
226
227 if let Some(cep) = &report.cep {
228 if let Some(last) = &cep.last_snapshot {
229 println!(
230 " cep(last): score={} cache_hit_rate={} mode_diversity={} compression_rate={} tool_calls={} tok_saved={}",
231 last.score,
232 last.cache_hit_rate,
233 last.mode_diversity,
234 last.compression_rate,
235 last.tool_calls,
236 last.tokens_saved
237 );
238 } else {
239 println!(" cep(last): (none)");
240 }
241 }
242
243 if !report.warnings.is_empty() {
244 println!(" warnings: {}", report.warnings.len());
245 }
246 if !report.errors.is_empty() {
247 println!(" errors: {}", report.errors.len());
248 }
249 println!(" report saved: {}", path.display());
250}
251
252fn extract_flag(args: &[String], flag: &str) -> Option<String> {
253 args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
254}