Skip to main content

lean_ctx/cli/
ledger_cmd.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use crate::core::cache::SessionCache;
5use crate::core::context_ledger::ContextLedger;
6use crate::core::tokens::count_tokens;
7use crate::tools::{CrpMode, ctx_read};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10struct PushOptions {
11    target: String,
12    depth: Option<usize>,
13    ignore: Vec<String>,
14}
15
16#[derive(Debug, Default, PartialEq, Eq)]
17struct PushSummary {
18    pushed: usize,
19    skipped: usize,
20    original_tokens: usize,
21    sent_tokens: usize,
22}
23
24impl PushSummary {
25    fn saved_tokens(&self) -> usize {
26        self.original_tokens.saturating_sub(self.sent_tokens)
27    }
28}
29
30fn parse_push_args(args: &[String]) -> Result<PushOptions, String> {
31    let mut target = None;
32    let mut depth = None;
33    let mut ignore = Vec::new();
34    let mut i = 0;
35
36    while i < args.len() {
37        let arg = &args[i];
38        if arg == "--depth" {
39            i += 1;
40            let value = args
41                .get(i)
42                .ok_or_else(|| "--depth requires a non-negative integer".to_string())?;
43            depth = Some(
44                value
45                    .parse::<usize>()
46                    .map_err(|_| format!("invalid depth '{value}'"))?,
47            );
48        } else if let Some(value) = arg.strip_prefix("--depth=") {
49            depth = Some(
50                value
51                    .parse::<usize>()
52                    .map_err(|_| format!("invalid depth '{value}'"))?,
53            );
54        } else if arg == "--ignore" {
55            i += 1;
56            let value = args
57                .get(i)
58                .ok_or_else(|| "--ignore requires a glob pattern".to_string())?;
59            if value.is_empty() {
60                return Err("--ignore requires a non-empty glob pattern".to_string());
61            }
62            ignore.push(value.clone());
63        } else if let Some(value) = arg.strip_prefix("--ignore=") {
64            if value.is_empty() {
65                return Err("--ignore requires a non-empty glob pattern".to_string());
66            }
67            ignore.push(value.to_string());
68        } else if arg.starts_with('-') {
69            return Err(format!("unknown ledger push flag '{arg}'"));
70        } else if target.replace(arg.clone()).is_some() {
71            return Err("ledger push accepts one file or directory path".to_string());
72        }
73        i += 1;
74    }
75
76    Ok(PushOptions {
77        target: target
78            .ok_or_else(|| "ledger push requires a file or directory path".to_string())?,
79        depth,
80        ignore,
81    })
82}
83
84fn resolve_push_path(raw: &str) -> PathBuf {
85    let path = Path::new(raw);
86    let path = if path.is_absolute() {
87        path.to_path_buf()
88    } else {
89        std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
90    };
91    std::fs::canonicalize(&path).unwrap_or(path)
92}
93
94fn ignore_matches(pattern: &glob::Pattern, relative: &str) -> bool {
95    let relative = relative.trim_matches('/');
96    if pattern.matches(relative) {
97        return true;
98    }
99
100    let pattern_text = pattern.as_str().trim_matches('/');
101    if let Some(prefix) = pattern_text.strip_suffix("/**")
102        && (relative == prefix || relative.starts_with(&format!("{prefix}/")))
103    {
104        return true;
105    }
106
107    !pattern_text.contains('/')
108        && relative
109            .split('/')
110            .any(|component| pattern.matches(component))
111}
112
113fn collect_push_files(
114    root: &Path,
115    depth: Option<usize>,
116    ignore_patterns: &[String],
117) -> Result<Vec<PathBuf>, String> {
118    if !root.exists() {
119        return Err(format!("{} does not exist", root.display()));
120    }
121    if root.is_file() {
122        return Ok(vec![root.to_path_buf()]);
123    }
124    if !root.is_dir() {
125        return Err(format!(
126            "{} is not a regular file or directory",
127            root.display()
128        ));
129    }
130
131    let root_string = root.to_string_lossy().into_owned();
132    if let Some(error) = crate::tools::walk_guard::deny_unsafe_walk_root(&root_string) {
133        return Err(error);
134    }
135
136    let patterns: Vec<glob::Pattern> = ignore_patterns
137        .iter()
138        .filter_map(|pattern| glob::Pattern::new(pattern).ok())
139        .collect();
140    let walk_root = crate::core::walk_filter::explicit_walk_root(root);
141    let filter_root = walk_root.clone();
142    let walker = ignore::WalkBuilder::new(&walk_root)
143        .hidden(true)
144        .git_ignore(true)
145        .git_global(true)
146        .git_exclude(true)
147        .require_git(false)
148        .max_depth(depth)
149        .filter_entry(move |entry| {
150            if !crate::core::walk_filter::keep_entry(entry) || entry.depth() == 0 {
151                return entry.depth() == 0;
152            }
153            let relative = entry
154                .path()
155                .strip_prefix(&filter_root)
156                .unwrap_or(entry.path())
157                .to_string_lossy()
158                .replace('\\', "/");
159            !patterns
160                .iter()
161                .any(|pattern| ignore_matches(pattern, &relative))
162        })
163        .sort_by_file_path(Path::cmp)
164        .build();
165
166    let mut files = walker
167        .filter_map(Result::ok)
168        .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
169        .filter(|entry| !entry.path_is_symlink())
170        .map(ignore::DirEntry::into_path)
171        .collect::<Vec<_>>();
172    files.sort();
173    Ok(files)
174}
175
176fn push_files_locally(files: &[PathBuf]) -> PushSummary {
177    let mut cache = SessionCache::new();
178    let mut summary = PushSummary::default();
179
180    for path in files {
181        let path_string = path.to_string_lossy().into_owned();
182        let raw = match ctx_read::read_file_lossy(&path_string) {
183            Ok(content) => content,
184            Err(error) => {
185                eprintln!("Skipping {path_string}: {error}");
186                summary.skipped += 1;
187                continue;
188            }
189        };
190        let original_tokens = count_tokens(&raw);
191        let started = Instant::now();
192        let output = ctx_read::handle_with_preread(
193            &mut cache,
194            &path_string,
195            "auto",
196            true,
197            CrpMode::effective(),
198            None,
199            None,
200            &[],
201            raw,
202        );
203        let sent_tokens = count_tokens(&output.content);
204        crate::core::tool_lifecycle::record_file_read(
205            &path_string,
206            &output.resolved_mode,
207            original_tokens,
208            sent_tokens,
209            output.is_cache_hit,
210            started.elapsed(),
211            &output.content,
212        );
213        summary.pushed += 1;
214        summary.original_tokens = summary.original_tokens.saturating_add(original_tokens);
215        summary.sent_tokens = summary.sent_tokens.saturating_add(sent_tokens);
216    }
217
218    crate::core::tool_lifecycle::flush_all();
219    summary
220}
221
222#[cfg(unix)]
223fn push_files_via_daemon(files: &[PathBuf]) -> Option<PushSummary> {
224    let first = files.first()?;
225    let read = |path: &Path| {
226        crate::daemon_client::try_daemon_tool_call_blocking_text(
227            "ctx_read",
228            Some(serde_json::json!({
229                "path": path.to_string_lossy(),
230                "mode": "auto",
231                "fresh": true,
232            })),
233        )
234    };
235    let first_output = read(first)?;
236    let mut summary = PushSummary::default();
237    for output in std::iter::once(first_output).chain(files[1..].iter().filter_map(|p| read(p))) {
238        if output.trim_start().starts_with("ERROR:") {
239            summary.skipped += 1;
240        } else {
241            summary.pushed += 1;
242        }
243    }
244    Some(summary)
245}
246
247#[cfg(not(unix))]
248fn push_files_via_daemon(_files: &[PathBuf]) -> Option<PushSummary> {
249    None
250}
251
252fn cmd_push(args: &[String]) {
253    let options = match parse_push_args(args) {
254        Ok(options) => options,
255        Err(error) => {
256            eprintln!("Error: {error}");
257            eprintln!("Usage: lean-ctx ledger push <path> [--depth N] [--ignore PATTERN]");
258            std::process::exit(1);
259        }
260    };
261    let root = resolve_push_path(&options.target);
262    let mut ignore_patterns = crate::core::config::Config::load().extra_ignore_patterns;
263    ignore_patterns.extend(options.ignore);
264    let files = match collect_push_files(&root, options.depth, &ignore_patterns) {
265        Ok(files) => files,
266        Err(error) => {
267            eprintln!("Error: {error}");
268            std::process::exit(1);
269        }
270    };
271    if files.is_empty() {
272        eprintln!("No files found under {}", root.display());
273        return;
274    }
275
276    let summary = push_files_via_daemon(&files).unwrap_or_else(|| push_files_locally(&files));
277    println!(
278        "Pushed {} file(s), skipped {}. Tokens: {} → {} ({} saved).",
279        summary.pushed,
280        summary.skipped,
281        summary.original_tokens,
282        summary.sent_tokens,
283        summary.saved_tokens(),
284    );
285}
286
287pub fn cmd_ledger(args: &[String]) {
288    let action = args.first().map_or("status", String::as_str);
289
290    match action {
291        "push" => cmd_push(&args[1..]),
292        "status" => {
293            #[cfg(unix)]
294            if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
295                "ctx_ledger",
296                Some(serde_json::json!({ "action": "status" })),
297            ) {
298                println!("{out}");
299                return;
300            }
301            let ledger = ContextLedger::load();
302            let pressure = ledger.pressure();
303            println!(
304                "Context pressure: {:.0}% ({}/{} tokens)",
305                pressure.utilization * 100.0,
306                ledger.total_tokens_sent,
307                ledger.window_size,
308            );
309            println!("Entries: {}", ledger.entries.len());
310            println!("Recommendation: {:?}", pressure.recommendation);
311            let top = ledger.files_by_token_cost();
312            if !top.is_empty() {
313                println!("Top files by cost:");
314                for (path, tokens) in top.iter().take(5) {
315                    println!("  {path} ({tokens} tok)");
316                }
317            }
318        }
319
320        "reset" => {
321            #[cfg(unix)]
322            if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
323                "ctx_ledger",
324                Some(serde_json::json!({ "action": "reset" })),
325            ) {
326                println!("{out}");
327                return;
328            }
329            let mut ledger = ContextLedger::load();
330            let prev_entries = ledger.entries.len();
331            let prev_tokens = ledger.total_tokens_sent;
332            ledger.reset();
333            ledger.save();
334            println!(
335                "Ledger reset. Removed {prev_entries} entries, freed {prev_tokens} tracked tokens. Pressure: 0%."
336            );
337        }
338
339        "evict" => {
340            let targets: Vec<&str> = args[1..].iter().map(String::as_str).collect();
341            if targets.is_empty() {
342                eprintln!("Usage: lean-ctx ledger evict <file1> [file2...]");
343                std::process::exit(1);
344            }
345
346            #[cfg(unix)]
347            {
348                let targets_joined = targets.join(", ");
349                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
350                    "ctx_ledger",
351                    Some(serde_json::json!({ "action": "evict", "targets": targets_joined })),
352                ) {
353                    println!("{out}");
354                    return;
355                }
356            }
357
358            let mut ledger = ContextLedger::load();
359            // #715: resolve partial paths/basenames and report each outcome.
360            let root = std::env::current_dir()
361                .ok()
362                .map(|d| d.to_string_lossy().into_owned());
363            let outcomes = ledger.evict_paths_resolved(&targets, root.as_deref());
364            let removed = outcomes.iter().filter(|o| o.resolved.is_some()).count();
365            ledger.save();
366            let pressure = ledger.pressure();
367            println!(
368                "Evicted {removed}/{} target(s). Pressure now: {:.0}%.",
369                targets.len(),
370                pressure.utilization * 100.0,
371            );
372            for outcome in &outcomes {
373                match (&outcome.resolved, outcome.ambiguous.is_empty()) {
374                    (Some(resolved), _) if resolved != &outcome.target => {
375                        println!("  {} → {resolved}", outcome.target);
376                    }
377                    (Some(_), _) => {}
378                    (None, false) => println!(
379                        "  {} is ambiguous ({}) — use a longer suffix",
380                        outcome.target,
381                        outcome.ambiguous.join(", ")
382                    ),
383                    (None, true) => println!("  {} not in ledger", outcome.target),
384                }
385            }
386        }
387
388        "prune" => {
389            let mut ledger = ContextLedger::load();
390            let pruned = ledger.prune();
391            ledger.save();
392            let pressure = ledger.pressure();
393            println!(
394                "Pruned {pruned} entries. Remaining: {}. Pressure: {:.0}%.",
395                ledger.entries.len(),
396                pressure.utilization * 100.0,
397            );
398        }
399
400        _ => {
401            eprintln!("Usage: lean-ctx ledger <status|reset|evict|prune|push> [args...]");
402            std::process::exit(1);
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::{PushOptions, collect_push_files, ignore_matches, parse_push_args};
410
411    fn args(values: &[&str]) -> Vec<String> {
412        values.iter().map(ToString::to_string).collect()
413    }
414
415    #[test]
416    fn parses_push_path_depth_and_repeatable_ignores() {
417        assert_eq!(
418            parse_push_args(&args(&[
419                "src",
420                "--depth",
421                "2",
422                "--ignore",
423                "*.generated",
424                "--ignore=vendor/**"
425            ])),
426            Ok(PushOptions {
427                target: "src".to_string(),
428                depth: Some(2),
429                ignore: vec!["*.generated".to_string(), "vendor/**".to_string()],
430            })
431        );
432    }
433
434    #[test]
435    fn rejects_missing_path_and_invalid_flags() {
436        assert!(parse_push_args(&[]).is_err());
437        assert!(parse_push_args(&args(&["src", "--depth", "nope"])).is_err());
438        assert!(parse_push_args(&args(&["src", "--unknown"])).is_err());
439    }
440
441    #[test]
442    fn collects_gitignored_and_depth_limited_files() {
443        let root = tempfile::tempdir().expect("tempdir");
444        std::fs::create_dir_all(root.path().join("nested/deeper")).expect("mkdir");
445        std::fs::write(root.path().join(".gitignore"), "ignored.rs\n").expect("gitignore");
446        std::fs::write(root.path().join("kept.rs"), "fn kept() {}\n").expect("write");
447        std::fs::write(root.path().join("ignored.rs"), "fn ignored() {}\n").expect("write");
448        std::fs::write(root.path().join("nested/visible.rs"), "fn visible() {}\n").expect("write");
449        std::fs::write(
450            root.path().join("nested/deeper/hidden.rs"),
451            "fn hidden() {}\n",
452        )
453        .expect("write");
454
455        let files = collect_push_files(root.path(), Some(2), &[]).expect("collect");
456        let names: Vec<_> = files
457            .iter()
458            .filter_map(|path| path.file_name().and_then(|name| name.to_str()))
459            .collect();
460        assert_eq!(names, vec!["kept.rs", "visible.rs"]);
461    }
462
463    #[test]
464    fn ignore_patterns_match_components_and_recursive_directories() {
465        let component = glob::Pattern::new("*.generated").expect("pattern");
466        let recursive = glob::Pattern::new("vendor/**").expect("pattern");
467        assert!(ignore_matches(&component, "src/model.generated"));
468        assert!(ignore_matches(&recursive, "vendor/pkg/lib.rs"));
469        assert!(!ignore_matches(&recursive, "src/vendor.rs"));
470    }
471}