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 i32::from(!report.errors.is_empty())
95 }
96 Err(e) => {
97 eprintln!("{e}");
98 2
99 }
100 }
101}
102
103fn build_report(project_root_override: Option<&str>) -> Result<(TokenReport, PathBuf), String> {
104 let generated_at = Utc::now();
105 let version = env!("CARGO_PKG_VERSION").to_string();
106
107 let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
108
109 let cwd = std::env::current_dir()
110 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
111 let project_root = project_root_override.map_or_else(
112 || crate::core::protocol::detect_project_root_or_cwd(&cwd),
113 std::string::ToString::to_string,
114 );
115
116 let mut warnings: Vec<String> = Vec::new();
117 let errors: Vec<String> = Vec::new();
118
119 let knowledge = ProjectKnowledge::load(&project_root).map(|k| ProjectKnowledgeSummary {
120 project_hash: k.project_hash.clone(),
121 active_facts: k.facts.iter().filter(|f| f.is_current()).count(),
122 archived_facts: k.facts.iter().filter(|f| !f.is_current()).count(),
123 patterns: k.patterns.len(),
124 history: k.history.len(),
125 updated_at: k.updated_at,
126 });
127
128 if knowledge.is_none() {
129 warnings.push("no project knowledge found".to_string());
130 }
131
132 let session = SessionState::load_latest().map(|s| {
133 let repeated_files = s.files_touched.iter().filter(|f| f.read_count > 1).count() as u32;
134 SessionSummary {
135 id: s.id,
136 started_at: s.started_at,
137 updated_at: s.updated_at,
138 tool_calls: s.stats.total_tool_calls,
139 tokens_saved: s.stats.total_tokens_saved,
140 tokens_input: s.stats.total_tokens_input,
141 cache_hits: s.stats.cache_hits,
142 files_read: s.stats.files_read,
143 commands_run: s.stats.commands_run,
144 repeated_files,
145 intents_total: s.intents.len() as u32,
146 intents_inferred: s.stats.intents_inferred,
147 intents_explicit: s.stats.intents_explicit,
148 }
149 });
150
151 if session.is_none() {
152 warnings.push("no active session found".to_string());
153 }
154
155 let store = crate::core::stats::load();
156 let last_snapshot = store.cep.scores.last().map(|s| CepSnapshot {
157 timestamp: s.timestamp.clone(),
158 score: s.score,
159 cache_hit_rate: s.cache_hit_rate,
160 mode_diversity: s.mode_diversity,
161 compression_rate: s.compression_rate,
162 tool_calls: s.tool_calls,
163 tokens_saved: s.tokens_saved,
164 complexity: s.complexity.clone(),
165 });
166
167 let cep = CepSummary {
168 sessions: store.cep.sessions,
169 total_cache_hits: store.cep.total_cache_hits,
170 total_cache_reads: store.cep.total_cache_reads,
171 total_tokens_original: store.cep.total_tokens_original,
172 total_tokens_compressed: store.cep.total_tokens_compressed,
173 last_snapshot,
174 };
175
176 let report_path = data_dir.join("report").join("latest.json");
177
178 let report = TokenReport {
179 schema_version: 1,
180 generated_at,
181 version,
182 project_root,
183 data_dir: data_dir.to_string_lossy().to_string(),
184 knowledge,
185 session,
186 cep: Some(cep),
187 warnings,
188 errors,
189 };
190
191 Ok((report, report_path))
192}
193
194fn print_human(report: &TokenReport, path: &Path) {
195 println!("lean-ctx token-report v{}", report.version);
196 println!(" project: {}", report.project_root);
197 println!(" data: {}", report.data_dir);
198
199 if let Some(k) = &report.knowledge {
200 println!(
201 " knowledge: {} active, {} archived, {} patterns, {} history",
202 k.active_facts, k.archived_facts, k.patterns, k.history
203 );
204 } else {
205 println!(" knowledge: (none)");
206 }
207
208 if let Some(s) = &report.session {
209 println!(
210 " session: {} calls, {} tok saved, {} files read ({} repeated), {} intents ({} inferred, {} explicit)",
211 s.tool_calls,
212 s.tokens_saved,
213 s.files_read,
214 s.repeated_files,
215 s.intents_total,
216 s.intents_inferred,
217 s.intents_explicit
218 );
219 } else {
220 println!(" session: (none)");
221 }
222
223 if let Some(cep) = &report.cep {
224 if let Some(last) = &cep.last_snapshot {
225 println!(
226 " cep(last): score={} cache_hit_rate={} mode_diversity={} compression_rate={} tool_calls={} tok_saved={}",
227 last.score,
228 last.cache_hit_rate,
229 last.mode_diversity,
230 last.compression_rate,
231 last.tool_calls,
232 last.tokens_saved
233 );
234 } else {
235 println!(" cep(last): (none)");
236 }
237 }
238
239 if !report.warnings.is_empty() {
240 println!(" warnings: {}", report.warnings.len());
241 }
242 if !report.errors.is_empty() {
243 println!(" errors: {}", report.errors.len());
244 }
245 println!(" report saved: {}", path.display());
246}
247
248fn extract_flag(args: &[String], flag: &str) -> Option<String> {
249 args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
250}