Skip to main content

open_loops/
cli.rs

1//! Command definitions and module orchestration.
2#[path = "cli_command.rs"]
3mod cli_command;
4pub use cli_command::{Cli, Command};
5
6use crate::config::Store;
7use crate::distill::Confidence;
8use crate::ignores::Ignores;
9use crate::index::Index;
10use crate::inventory::InventoryStore;
11use crate::scanner::{self, OpenLoop, ScanOptions};
12use crate::state::State;
13use crate::{cache, distill, output, sessions, worktrees};
14use anyhow::{bail, ensure, Result};
15use sessions::SessionExcerpt;
16use std::path::{Path, PathBuf};
17
18struct ResumeEvidence {
19    default_branch: String,
20    commits: String,
21    diffstat: String,
22    excerpts: Vec<SessionExcerpt>,
23    confidence: Confidence,
24}
25
26type ScanResult = (
27    Vec<OpenLoop>,
28    Vec<(String, crate::inventory::InventoryFile)>,
29);
30
31fn progress(msg: &str) {
32    eprintln!("{msg}");
33}
34
35/// Loads config and enforces the invariant that at least one root is registered.
36/// Every command that scans repos shares this preamble; centralizing it keeps the
37/// "no roots" guidance identical across all entry points.
38fn load_cfg_with_roots(base: &Path) -> Result<(Store, crate::config::Config)> {
39    let store = Store::new(base.to_path_buf());
40    let cfg = store.load()?;
41    ensure!(
42        !cfg.roots.is_empty(),
43        "no roots configured. Run: loops init <dir-with-your-repos>"
44    );
45    Ok((store, cfg))
46}
47
48/// Builds the query `Candidate` view of a loop. `key` is borrowed by the returned
49/// Candidate, so the caller must own it (via `l.key()`) for at least as long as
50/// the Candidate — hence it is passed in rather than computed here.
51fn candidate_of<'a>(l: &'a OpenLoop, key: &'a str, ignored: bool) -> crate::query::Candidate<'a> {
52    crate::query::Candidate {
53        repo_name: &l.repo_name,
54        branch: &l.branch,
55        key,
56        last_commit: l.last_commit,
57        ahead: l.ahead,
58        behind: l.behind,
59        ignored,
60    }
61}
62
63fn resolve_plan_persisting(
64    base: &Path,
65    cfg: &crate::config::Config,
66    query: &str,
67) -> Result<crate::query::ScanPlan> {
68    let mut state = State::load(base)?;
69    let plan = crate::query::resolve_plan(
70        query,
71        cfg,
72        &crate::query::ResolveOptions {
73            current_context: state.current_context(),
74        },
75    )?;
76
77    match crate::query::context_persistence_from_query(query)? {
78        crate::query::ContextPersistence::Set(name) => {
79            state.set_current_context(Some(name))?;
80        }
81        crate::query::ContextPersistence::Clear => {
82            state.set_current_context(None)?;
83        }
84        crate::query::ContextPersistence::Unchanged => {}
85    }
86
87    Ok(plan)
88}
89
90/// Writes inventory updates produced by a scan to disk.
91fn write_inventory(
92    inv_store: &InventoryStore,
93    updates: Vec<(String, crate::inventory::InventoryFile)>,
94) {
95    for (hash, file) in updates {
96        if let Err(e) = inv_store.save(&hash, &file) {
97            eprintln!(
98                "warning: failed to write inventory for {}: {e:#}",
99                file.repo_path.display()
100            );
101        }
102    }
103}
104
105/// Scans with inventory write-through and returns the found loops and raw inventory
106/// updates. Prints scan warnings to stderr. The caller may filter `inv_updates`
107/// before writing (as in `run_refresh`) or write them directly (as in `resolve_loop`
108/// and `run_list`).
109#[allow(clippy::too_many_arguments)]
110fn scan_with_inventory(
111    base: &Path,
112    cfg: &crate::config::Config,
113    plan: &crate::query::ScanPlan,
114    roots: &[PathBuf],
115    labels: &[(PathBuf, String)],
116    need_ahead_behind: bool,
117    fresh: bool,
118    index: Option<&Index>,
119) -> Result<ScanResult> {
120    let inv_store = InventoryStore::new(base);
121    let opts = ScanOptions {
122        need_ahead_behind,
123        fresh,
124        inventory_dir: Some(inv_store.dir.clone()),
125        inventory_ttl_secs: cfg.inventory_ttl_secs,
126    };
127    // Only the first repo filter is pushed into the scan as a directory-walk
128    // narrowing hint; any remaining repo filters (and all other filters) are
129    // applied afterward in memory by ScanPlan::matches, so correctness does not
130    // depend on the scan honoring more than one.
131    let (found, warnings, inv_updates) = scanner::scan_indexed(
132        roots,
133        labels,
134        cfg.scan_depth,
135        &opts,
136        plan.repo_filters.first().map(|s| s.as_str()),
137        index,
138    );
139    for w in &warnings {
140        eprintln!("warning: {w}");
141    }
142    Ok((found, inv_updates))
143}
144
145fn resolve_loop(base: &Path, query: &str, fresh: bool) -> Result<OpenLoop> {
146    let (_store, cfg) = load_cfg_with_roots(base)?;
147    let mut plan = resolve_plan_persisting(base, &cfg, query)?;
148    plan.include_ignored = true; // resume can target an ignored loop by key
149    let labels = cfg.resolve_labels()?;
150    let roots = cfg.resolve_scan_roots(&plan)?;
151    let inv_store = InventoryStore::new(base);
152    // Open the disposable SQLite index once per command. `Index::open` is
153    // tolerant: a corrupt/unopenable db self-heals (rebuild or in-memory
154    // fallback), so this never aborts the command.
155    let index = Index::open(base);
156    let (found, inv_updates) = scan_with_inventory(
157        base,
158        &cfg,
159        &plan,
160        &roots,
161        &labels,
162        plan.need_ahead_behind,
163        fresh,
164        Some(&index),
165    )?;
166    write_inventory(&inv_store, inv_updates);
167    let now = chrono::Utc::now();
168    let matches: Vec<&OpenLoop> = found
169        .iter()
170        .filter(|l| {
171            let key = l.key();
172            plan.matches(&candidate_of(l, &key, false), now)
173        })
174        .collect();
175    match matches.len() {
176        0 => bail!("no loop matches '{query}'. Run `loops` to see open ones."),
177        1 => Ok(matches[0].clone()),
178        _ => bail!(
179            "ambiguous query, candidates:\n{}",
180            matches
181                .iter()
182                .map(|l| format!("  {}", l.key()))
183                .collect::<Vec<_>>()
184                .join("\n")
185        ),
186    }
187}
188
189fn gather_resume_evidence(base: &Path, lp: &OpenLoop) -> Result<ResumeEvidence> {
190    let store = Store::new(base.to_path_buf());
191    let cfg = store.load()?;
192    let default_branch = scanner::default_branch(&lp.repo_path)?;
193    let commits = scanner::git_log(&lp.repo_path, &default_branch, &lp.branch)?;
194    let diffstat = scanner::diffstat(&lp.repo_path, &default_branch, &lp.branch)?;
195    let window = scanner::commit_window(&lp.repo_path, &default_branch, &lp.branch)?;
196    progress("matching AI sessions…");
197    let source = sessions::claude_code::ClaudeCode {
198        projects_dir: cfg.sessions_dir.clone(),
199    };
200    // Open the disposable index and run the FTS-accelerated mention probe (#14).
201    // `Index::open` is tolerant, and `excerpts_indexed` degrades to the in-memory
202    // file probe on any index error — so this never aborts resume.
203    let index = Index::open(base);
204    let excerpts = source.excerpts_indexed(
205        &lp.repo_path,
206        &lp.branch,
207        window,
208        cfg.max_sessions,
209        cfg.max_session_kb,
210        Some(&index),
211    )?;
212    let confidence = distill::compute_confidence(&excerpts);
213    Ok(ResumeEvidence {
214        default_branch,
215        commits,
216        diffstat,
217        excerpts,
218        confidence,
219    })
220}
221
222/// Backs `loops [query]`: persists any `@context` switch, scans the matching
223/// repos, and renders the inventory table to stdout.
224pub fn run_list(base: &Path, query: &str, fresh: bool) -> Result<()> {
225    let (_store, cfg) = load_cfg_with_roots(base)?;
226    let mut plan = resolve_plan_persisting(base, &cfg, query)?;
227    plan.need_ahead_behind = true; // table always renders AHEAD/BEHIND columns
228    let labels = cfg.resolve_labels()?;
229    let roots = cfg.resolve_scan_roots(&plan)?;
230    progress("scanning git repositories…");
231    let inv_store = InventoryStore::new(base);
232    let index = Index::open(base);
233    let need_ahead_behind = true;
234    let (found, inv_updates) = scan_with_inventory(
235        base,
236        &cfg,
237        &plan,
238        &roots,
239        &labels,
240        need_ahead_behind,
241        fresh,
242        Some(&index),
243    )?;
244    write_inventory(&inv_store, inv_updates);
245    let ignores = Ignores::load(base)?;
246    let now = chrono::Utc::now();
247    let visible: Vec<OpenLoop> = found
248        .into_iter()
249        .filter(|l| {
250            let key = l.key();
251            plan.matches(&candidate_of(l, &key, ignores.contains(&key)), now)
252        })
253        .collect();
254    if visible.is_empty() && !query.trim().is_empty() {
255        eprintln!("No loops match: {query}");
256        eprintln!("(hint: run `loops` to list all)");
257    }
258    print!("{}", output::render_table(&visible, now));
259    Ok(())
260}
261
262/// Backs `loops init`: registers repository roots in the config so later scans
263/// know where to look.
264pub fn run_init(base: &Path, paths: &[PathBuf]) -> Result<()> {
265    ensure!(!paths.is_empty(), "usage: loops init <dir> [<dir>...]");
266    let store = Store::new(base.to_path_buf());
267    let cfg = store.add_roots(paths)?;
268    println!("roots registered:");
269    for r in &cfg.roots {
270        println!("  {}", r.display());
271    }
272    println!("\nconfig at {}", store.config_path().display());
273    Ok(())
274}
275
276/// Backs `loops ignore`: persists a `repo/branch` key to the ignore list so it
277/// no longer surfaces as an open loop.
278pub fn run_ignore(base: &Path, key: &str) -> Result<()> {
279    ensure!(
280        key.contains('/'),
281        "expected format: repo/branch (run `loops` to see the keys)"
282    );
283    let mut ignores = Ignores::load(base)?;
284    ignores.add(key)?;
285    println!("ignored: {key}");
286    Ok(())
287}
288
289/// Backs `loops resume`: resolves the single matching loop and distills its
290/// context via the LLM, serving from cache when possible. `dry_run` prints the
291/// matched evidence without calling the LLM.
292pub fn run_resume(base: &Path, query: &str, dry_run: bool, fresh: bool) -> Result<()> {
293    progress("scanning git…");
294    let lp = resolve_loop(base, query, fresh)?;
295
296    if dry_run {
297        let evidence = gather_resume_evidence(base, &lp)?;
298        let doc = distill::format_dry_run(
299            &lp,
300            &evidence.default_branch,
301            &evidence.commits,
302            &evidence.diffstat,
303            &evidence.excerpts,
304            evidence.confidence,
305        );
306        print!("{doc}");
307        return Ok(());
308    }
309
310    let cache = cache::Cache::new(base);
311    if let Some(hit) = cache.get(&lp) {
312        println!("{hit}");
313        return Ok(());
314    }
315
316    let evidence = gather_resume_evidence(base, &lp)?;
317    let prompt = distill::build_prompt(
318        &lp,
319        &evidence.default_branch,
320        &evidence.commits,
321        &evidence.diffstat,
322        &evidence.excerpts,
323    );
324    let store = Store::new(base.to_path_buf());
325    let cfg = store.load()?;
326    progress("distilling…");
327    let answer = distill::run_llm(&cfg.llm_command, &prompt)?;
328    let doc = distill::with_sources(&answer, &lp, &evidence.excerpts, evidence.confidence);
329    cache.put(&lp, &doc)?;
330    println!("{doc}");
331    Ok(())
332}
333
334/// Reindexes ahead/behind for all repos matching `query` (or all repos when
335/// `query` is empty), writes the updated inventory, and prunes orphan files.
336pub fn run_refresh(base: &Path, query: &str) -> Result<()> {
337    let (_store, cfg) = load_cfg_with_roots(base)?;
338    let plan = resolve_plan_persisting(base, &cfg, query)?;
339    let labels = cfg.resolve_labels()?;
340    let roots = cfg.resolve_scan_roots(&plan)?;
341    progress("scanning git repositories…");
342    let inv_store = InventoryStore::new(base);
343    // `fresh: true` bypasses the gate but still writes through, so the index's
344    // `loops`/`repos` rows are rebuilt for the scoped repos on this scan.
345    let index = Index::open(base);
346    let need_ahead_behind = true;
347    let fresh = true; // refresh always recomputes — ignores any cached memo
348    let (found, inv_updates) = scan_with_inventory(
349        base,
350        &cfg,
351        &plan,
352        &roots,
353        &labels,
354        need_ahead_behind,
355        fresh,
356        Some(&index),
357    )?;
358
359    // Scope the reindex to the loops the query would actually list. `repo:`/`root:`
360    // are already pushed down into the scan, but bare terms and branch:/key:/idle:/
361    // ahead:/behind: filters only narrow in memory — so apply them here too, or
362    // `loops refresh beta` would rewrite every repo instead of the matching ones.
363    // A repo is reindexed when at least one of its loops matches; we correlate a
364    // loop to its inventory file by HEAD sha (globally unique, and worktree-safe:
365    // two worktrees share one common-dir file but keep distinct branch HEADs).
366    let scoped = if has_in_memory_filter(&plan) {
367        let ignores = Ignores::load(base)?;
368        let now = chrono::Utc::now();
369        let matching: std::collections::HashSet<&str> = found
370            .iter()
371            .filter(|l| {
372                let key = l.key();
373                plan.matches(&candidate_of(l, &key, ignores.contains(&key)), now)
374            })
375            .map(|l| l.head_sha.as_str())
376            .collect();
377        inv_updates
378            .into_iter()
379            .filter(|(_, file)| {
380                file.loops
381                    .iter()
382                    .any(|m| matching.contains(m.head_sha.as_str()))
383            })
384            .collect()
385    } else {
386        inv_updates
387    };
388
389    let n = scoped.len();
390    write_inventory(&inv_store, scoped);
391    inv_store.prune_orphans()?;
392    // Reclaim index rows for repos gone from disk (same orphan semantics as the
393    // inventory prune above). Tolerant: a failure here never aborts refresh.
394    index.prune_missing_repos();
395    let noun = if n == 1 { "repo" } else { "repos" };
396    eprintln!("refreshed {n} {noun}");
397    Ok(())
398}
399
400/// True when the plan carries filters that `scanner::scan` cannot push down to
401/// repo scope and that are only applied in memory (bare terms, branch/key
402/// substrings, attribute comparisons). When false, the `repo:`/`root:` push-down
403/// has already scoped the scan and every scanned repo is in scope.
404fn has_in_memory_filter(plan: &crate::query::ScanPlan) -> bool {
405    !plan.terms.is_empty()
406        || !plan.branch_filters.is_empty()
407        || !plan.key_filters.is_empty()
408        || !plan.attr_filters.is_empty()
409}
410
411pub fn run_completions(shell: clap_complete::Shell) -> Result<()> {
412    use clap::CommandFactory;
413    let mut cmd = Cli::command();
414    clap_complete::generate(shell, &mut cmd, "loops", &mut std::io::stdout());
415    Ok(())
416}
417
418pub fn run_worktrees(base: &Path) -> Result<()> {
419    let (_store, cfg) = load_cfg_with_roots(base)?;
420    progress("scanning git worktrees…");
421    let (wts, warnings) = worktrees::scan_worktrees(&cfg.roots, cfg.scan_depth);
422    for w in &warnings {
423        eprintln!("warning: {w}");
424    }
425    print!("{}", output::render_worktrees(&wts, chrono::Utc::now()));
426    Ok(())
427}