Skip to main content

lean_ctx/
token_report.rs

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}