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            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}