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