Skip to main content

blotter/commands/
sweep.rs

1use crate::cli::{ListKind, SweepArgs};
2use crate::error::{AppError, AppResult};
3use crate::output::{self, Meta};
4use crate::store;
5use crate::{ItemStatus, ListItem, parse_since};
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Serialize, Deserialize)]
13pub struct SweepData {
14    pub repos: Vec<SweepRepo>,
15    pub totals: SweepTotals,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct SweepRepo {
20    pub path: String,
21    pub counts: SweepCounts,
22    pub by_tag: Vec<TagCount>,
23    pub items: Vec<ListItem>,
24    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
25    pub truncated: bool,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29pub struct SweepCounts {
30    pub open_cuts: usize,
31    pub open_dogears: usize,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35pub struct TagCount {
36    pub tag: String,
37    pub count: usize,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct SweepTotals {
42    pub repos_swept: usize,
43    pub repos_skipped: usize,
44    pub open_cuts: usize,
45    pub open_dogears: usize,
46}
47
48pub fn run(args: SweepArgs, file: Option<PathBuf>, pretty: bool, now: Timestamp) -> AppResult<i32> {
49    if file.is_some() {
50        return Err(AppError::invalid_argument(
51            "--file conflicts with sweep",
52            "List repository paths directly or use --registry FILE.",
53        ));
54    }
55    let since = args
56        .since
57        .as_deref()
58        .map(|value| parse_since(value, now))
59        .transpose()?;
60    let inputs = input_paths(&args)?;
61    if inputs.is_empty() {
62        return Err(AppError::invalid_argument(
63            "nothing to sweep",
64            "Pass one or more repository paths or --registry FILE.",
65        ));
66    }
67
68    let mut warnings = Vec::new();
69    let mut paths = BTreeSet::new();
70    let mut repos_skipped = 0;
71    for input in inputs {
72        match resolve_log_path(&input) {
73            Ok(path) => {
74                paths.insert(path);
75            }
76            Err(reason) => {
77                repos_skipped += 1;
78                warnings.push(format!("skipped {}: {reason}", input.display()));
79            }
80        }
81    }
82
83    let mut repos = Vec::new();
84    let mut totals = SweepTotals {
85        repos_swept: 0,
86        repos_skipped: 0,
87        open_cuts: 0,
88        open_dogears: 0,
89    };
90    let mut hidden_auto_captures = 0;
91    for path in paths {
92        match store::with_shared(&path, |file| {
93            let bytes = store::read_bytes(file, &path)?;
94            Ok(store::fold_bytes(&bytes))
95        }) {
96            Ok(folded) => {
97                for warning in folded.warnings {
98                    warnings.push(format!("{}: {warning}", path.display()));
99                }
100                let (items, auto_captures) =
101                    crate::partition_auto_captures(folded.items, args.include_auto);
102                hidden_auto_captures += auto_captures
103                    .iter()
104                    .filter(|item| item.status == ItemStatus::Open)
105                    .count();
106                let repo = sweep_repo(path, items, args.kind, since);
107                totals.repos_swept += 1;
108                totals.open_cuts += repo.counts.open_cuts;
109                totals.open_dogears += repo.counts.open_dogears;
110                repos.push(repo);
111            }
112            Err(error) => {
113                repos_skipped += 1;
114                let reason = if error.code == "lock_timeout" {
115                    "lock timeout (retryable)".into()
116                } else {
117                    error.message
118                };
119                warnings.push(format!("skipped {}: {reason}", path.display()));
120            }
121        }
122    }
123    totals.repos_skipped = repos_skipped;
124    if hidden_auto_captures > 0 {
125        warnings.push(crate::auto_capture_warning(hidden_auto_captures));
126    }
127
128    let mut meta = Meta::new();
129    meta.warnings = warnings;
130    output::write_success(SweepData { repos, totals }, pretty, meta)
131        .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
132    Ok(0)
133}
134
135fn input_paths(args: &SweepArgs) -> AppResult<Vec<PathBuf>> {
136    let mut paths = args.paths.clone();
137    if let Some(registry) = args.registry.as_deref() {
138        paths.extend(read_registry(registry)?);
139    }
140    Ok(paths)
141}
142
143fn read_registry(registry: &Path) -> AppResult<Vec<PathBuf>> {
144    let registry =
145        fs::canonicalize(registry).map_err(|error| AppError::from_io(error, registry))?;
146    let contents =
147        fs::read_to_string(&registry).map_err(|error| AppError::from_io(error, &registry))?;
148    let directory = registry.parent().unwrap_or(Path::new("."));
149    Ok(contents
150        .lines()
151        .map(str::trim)
152        .filter(|line| !line.is_empty() && !line.starts_with('#'))
153        .map(PathBuf::from)
154        .map(|path| {
155            if path.is_absolute() {
156                path
157            } else {
158                directory.join(path)
159            }
160        })
161        .collect())
162}
163
164fn resolve_log_path(input: &Path) -> Result<PathBuf, String> {
165    let input = fs::canonicalize(input).map_err(|error| format!("cannot resolve path: {error}"))?;
166    let metadata = fs::metadata(&input).map_err(|error| format!("cannot inspect path: {error}"))?;
167    let log = if metadata.is_dir() {
168        let root =
169            store::find_repo_root(&input).ok_or_else(|| "not a repository directory".to_owned())?;
170        store::default_log_path(&root)
171    } else if metadata.is_file() {
172        input
173    } else {
174        return Err("must be a repository directory or regular JSONL file".into());
175    };
176    fs::canonicalize(&log).map_err(|error| {
177        if error.kind() == std::io::ErrorKind::NotFound {
178            "blotter file does not exist".into()
179        } else {
180            format!("cannot resolve blotter file: {error}")
181        }
182    })
183}
184
185fn sweep_repo(
186    path: PathBuf,
187    items: Vec<ListItem>,
188    kind: ListKind,
189    since: Option<Timestamp>,
190) -> SweepRepo {
191    let counts = SweepCounts {
192        open_cuts: items
193            .iter()
194            .filter(|item| item.kind == "cut" && item.status == ItemStatus::Open)
195            .count(),
196        open_dogears: items
197            .iter()
198            .filter(|item| item.kind == "dogear" && item.status == ItemStatus::Open)
199            .count(),
200    };
201    let items: Vec<_> = items
202        .into_iter()
203        .filter(|item| item.status == ItemStatus::Open)
204        .filter(|item| matches_kind(item, kind))
205        .filter(|item| {
206            since.is_none_or(|threshold| {
207                item.ts
208                    .parse::<Timestamp>()
209                    .is_ok_and(|timestamp| timestamp >= threshold)
210            })
211        })
212        .collect();
213    let by_tag = tag_counts(&items);
214    let truncated = items.len() > 50;
215
216    SweepRepo {
217        path: path.to_string_lossy().into_owned(),
218        counts,
219        by_tag,
220        items: items.into_iter().take(50).collect(),
221        truncated,
222    }
223}
224
225fn matches_kind(item: &ListItem, kind: ListKind) -> bool {
226    match kind {
227        ListKind::Cut => item.kind == "cut",
228        ListKind::Dogear => item.kind == "dogear",
229        ListKind::All => true,
230    }
231}
232
233fn tag_counts(items: &[ListItem]) -> Vec<TagCount> {
234    let mut tags = BTreeMap::<String, usize>::new();
235    for item in items {
236        if item.tags.is_empty() {
237            *tags.entry(String::new()).or_default() += 1;
238        } else {
239            for tag in &item.tags {
240                *tags.entry(tag.clone()).or_default() += 1;
241            }
242        }
243    }
244    let mut tags: Vec<_> = tags
245        .into_iter()
246        .map(|(tag, count)| TagCount { tag, count })
247        .collect();
248    tags.sort_by(|left, right| {
249        right
250            .count
251            .cmp(&left.count)
252            .then_with(|| left.tag.cmp(&right.tag))
253    });
254    tags
255}