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