blotter/commands/
sweep.rs1use 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(®istry).map_err(|error| AppError::from_io(error, ®istry))?;
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}