Skip to main content

blotter/commands/
sweep.rs

1use crate::cli::{SweepArgs, SweepKind};
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    for path in paths {
91        match store::with_shared(&path, |file| {
92            let bytes = store::read_bytes(file, &path)?;
93            store::check_version(&bytes, &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 repo = sweep_repo(path, folded.items, args.kind, since);
101                totals.repos_swept += 1;
102                totals.open_cuts += repo.counts.open_cuts;
103                totals.open_dogears += repo.counts.open_dogears;
104                repos.push(repo);
105            }
106            Err(error) => {
107                repos_skipped += 1;
108                let reason = if error.code == "lock_timeout" {
109                    "lock timeout (retryable)".into()
110                } else {
111                    error.message
112                };
113                warnings.push(format!("skipped {}: {reason}", path.display()));
114            }
115        }
116    }
117    totals.repos_skipped = repos_skipped;
118    let mut meta = Meta::new();
119    meta.warnings = warnings;
120    output::write_success(SweepData { repos, totals }, pretty, meta)
121        .map_err(|error| AppError::from_io(error, Path::new("stdout")))?;
122    Ok(0)
123}
124
125fn input_paths(args: &SweepArgs) -> AppResult<Vec<PathBuf>> {
126    let mut paths = args.paths.clone();
127    if let Some(registry) = args.registry.as_deref() {
128        paths.extend(read_registry(registry)?);
129    }
130    Ok(paths)
131}
132
133fn read_registry(registry: &Path) -> AppResult<Vec<PathBuf>> {
134    let registry = fs::canonicalize(registry)
135        .map_err(|error| AppError::from_registry_file(error, registry))?;
136    let contents = fs::read_to_string(&registry)
137        .map_err(|error| AppError::from_registry_file(error, &registry))?;
138    let directory = registry.parent().unwrap_or(Path::new("."));
139    Ok(contents
140        .lines()
141        .map(str::trim)
142        .filter(|line| !line.is_empty() && !line.starts_with('#'))
143        .map(PathBuf::from)
144        .map(|path| {
145            if path.is_absolute() {
146                path
147            } else {
148                directory.join(path)
149            }
150        })
151        .collect())
152}
153
154fn resolve_log_path(input: &Path) -> Result<PathBuf, String> {
155    let input = fs::canonicalize(input).map_err(|error| format!("cannot resolve path: {error}"))?;
156    let metadata = fs::metadata(&input).map_err(|error| format!("cannot inspect path: {error}"))?;
157    let log = if metadata.is_dir() {
158        let root =
159            store::find_repo_root(&input).ok_or_else(|| "not a repository directory".to_owned())?;
160        store::default_log_path(&root)
161    } else if metadata.is_file() {
162        input
163    } else {
164        return Err("must be a repository directory or regular JSONL file".into());
165    };
166    fs::canonicalize(&log).map_err(|error| {
167        if error.kind() == std::io::ErrorKind::NotFound {
168            "blotter file does not exist".into()
169        } else {
170            format!("cannot resolve blotter file: {error}")
171        }
172    })
173}
174
175fn sweep_repo(
176    path: PathBuf,
177    items: Vec<ListItem>,
178    kind: SweepKind,
179    since: Option<Timestamp>,
180) -> SweepRepo {
181    let counts = SweepCounts {
182        open_cuts: items
183            .iter()
184            .filter(|item| item.kind == "cut" && item.status == ItemStatus::Open)
185            .count(),
186        open_dogears: items
187            .iter()
188            .filter(|item| item.kind == "dogear" && item.status == ItemStatus::Open)
189            .count(),
190    };
191    let items: Vec<_> = items
192        .into_iter()
193        .filter(|item| item.status == ItemStatus::Open)
194        .filter(|item| matches_kind(item, kind))
195        .filter(|item| {
196            since.is_none_or(|threshold| {
197                item.ts
198                    .parse::<Timestamp>()
199                    .is_ok_and(|timestamp| timestamp >= threshold)
200            })
201        })
202        .collect();
203    let by_tag = tag_counts(&items);
204    let truncated = items.len() > 50;
205
206    SweepRepo {
207        path: path.to_string_lossy().into_owned(),
208        counts,
209        by_tag,
210        items: items.into_iter().take(50).collect(),
211        truncated,
212    }
213}
214
215fn matches_kind(item: &ListItem, kind: SweepKind) -> bool {
216    match kind {
217        SweepKind::Cut => item.kind == "cut",
218        SweepKind::Dogear => item.kind == "dogear",
219        SweepKind::All => true,
220    }
221}
222
223fn tag_counts(items: &[ListItem]) -> Vec<TagCount> {
224    let mut tags = BTreeMap::<String, usize>::new();
225    for item in items {
226        if item.tags.is_empty() {
227            *tags.entry(String::new()).or_default() += 1;
228        } else {
229            for tag in &item.tags {
230                *tags.entry(tag.clone()).or_default() += 1;
231            }
232        }
233    }
234    let mut tags: Vec<_> = tags
235        .into_iter()
236        .map(|(tag, count)| TagCount { tag, count })
237        .collect();
238    tags.sort_by(|left, right| {
239        right
240            .count
241            .cmp(&left.count)
242            .then_with(|| left.tag.cmp(&right.tag))
243    });
244    tags
245}