Skip to main content

artifactize_core/monitor/
catalog.rs

1//! Repository/worktree selection and cached Git discovery; no artifact config is read here.
2#[cfg(test)]
3mod tests;
4use super::model::Status;
5use crate::{
6    repository::{self, Identity, Worktree},
7    store::{CatalogRun, Signoff},
8    types::RunStatus,
9};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    path::{Path, PathBuf},
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub enum Repository {
17    Git(PathBuf),
18    Workspace(PathBuf),
19}
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
21pub enum Scope {
22    All,
23    Repository(Repository),
24    Worktree(Repository, PathBuf),
25}
26#[derive(Debug, Clone, Default)]
27pub struct Badge {
28    pub waiting: BTreeSet<Signoff>,
29    pub red: u64,
30    pub error: u64,
31    pub running: u64,
32}
33impl Badge {
34    fn add(&mut self, run: &CatalogRun) {
35        self.waiting.extend(run.waiting.iter().cloned());
36        self.red += run.red;
37        self.error += run.error;
38        self.running += u64::from(run.status == RunStatus::Running);
39    }
40    /// Status glyphs and counts in the urgency order of the header, Runs and the tree:
41    /// `!1 โœ—2 โ—1 ?1`.
42    pub fn parts(&self) -> Vec<(Status, u64)> {
43        let mut parts: Vec<_> = [
44            (Status::WaitingHuman, self.waiting.len() as u64),
45            (Status::Red, self.red),
46            (Status::Error, self.error),
47            (Status::Running, self.running),
48        ]
49        .into_iter()
50        .filter(|(_, count)| *count > 0)
51        .collect();
52        parts.sort_by_key(|(status, _)| *status);
53        parts
54    }
55}
56#[derive(Debug, Clone)]
57pub struct CatalogRow {
58    pub scope: Scope,
59    pub label: String,
60    pub badge: Badge,
61    pub depth: usize,
62    /// A row standing for this repository's worktrees without Runs; Space shows or hides them.
63    pub fold: Option<Repository>,
64}
65#[derive(Clone, Default)]
66pub struct Catalog {
67    facts: std::sync::Arc<Vec<CatalogRun>>,
68    initial_path: Option<PathBuf>,
69    identities: BTreeMap<PathBuf, Identity>,
70    trees: BTreeMap<PathBuf, Vec<Worktree>>,
71    pub rows: Vec<CatalogRow>,
72    paths: BTreeMap<Scope, BTreeSet<PathBuf>>,
73    /// Repositories whose worktrees without Runs are shown.
74    expanded: BTreeSet<Repository>,
75    /// The selected scope stays listed even without Runs.
76    pub selected: Option<Scope>,
77}
78impl Catalog {
79    /// Rediscover Git metadata without losing the selected scope or worktree folds.
80    pub(super) fn reset_git(&mut self) {
81        self.identities.clear();
82        self.trees.clear();
83    }
84    fn identity(&mut self, path: &Path) -> Identity {
85        self.identities
86            .entry(path.to_path_buf())
87            .or_insert_with(|| repository::identify(path))
88            .clone()
89    }
90    fn trees(&mut self, common_dir: &Path) -> Vec<Worktree> {
91        self.trees
92            .entry(common_dir.to_path_buf())
93            .or_insert_with(|| repository::worktrees(common_dir))
94            .clone()
95    }
96    pub fn initial(&mut self, path: Option<&Path>) -> Scope {
97        path.map_or(Scope::All, |path| {
98            self.initial_path(path, &self.paths.clone())
99        })
100    }
101    fn initial_path(&mut self, path: &Path, paths: &BTreeMap<Scope, BTreeSet<PathBuf>>) -> Scope {
102        if let Some((scope, _)) = paths.iter().find(|(scope, workspaces)| {
103            matches!(scope, Scope::Worktree(_, tree) if crate::platform::paths_equal(tree, path) || workspaces.iter().any(|workspace| crate::platform::paths_equal(workspace, path)))
104        }) {
105            return scope.clone();
106        }
107        let identity = self.identity(path);
108        if let (Some(common), Some(tree)) = (identity.common_dir, identity.worktree_path) {
109            return Scope::Worktree(Repository::Git(common), tree);
110        }
111        // Initial selection only: a known non-Git workspace can contain cwd. Choose the
112        // closest ancestor by components, never a string prefix or inferred Git grouping.
113        paths
114            .iter()
115            .filter_map(|(scope, workspaces)| match scope {
116                Scope::Worktree(Repository::Workspace(_), _) => workspaces
117                    .iter()
118                    .filter(|workspace| crate::platform::is_within(path, workspace))
119                    .map(|workspace| (workspace.components().count(), scope))
120                    .max_by_key(|(depth, _)| *depth),
121                _ => None,
122            })
123            .max_by_key(|(depth, _)| *depth)
124            .map(|(_, scope)| scope.clone())
125            .unwrap_or_else(|| {
126                Scope::Worktree(
127                    Repository::Workspace(path.to_path_buf()),
128                    path.to_path_buf(),
129                )
130            })
131    }
132    /// Old-path discovery stays cached between resets, including database-only refreshes.
133    pub fn update(&mut self, runs: &[CatalogRun], initial: Option<&Path>) {
134        self.facts = std::sync::Arc::new(runs.to_vec());
135        self.initial_path = initial.map(Path::to_path_buf);
136        let mut badges: BTreeMap<Scope, Badge> = BTreeMap::new();
137        let mut paths: BTreeMap<Scope, BTreeSet<PathBuf>> = BTreeMap::new();
138        let mut branches: BTreeMap<Scope, Option<String>> = BTreeMap::new();
139        for run in runs {
140            let identity =
141                if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
142                    run.repository.clone()
143                } else {
144                    self.identity(&run.repo_path)
145                };
146            let repository = identity.common_dir.clone().map_or_else(
147                || Repository::Workspace(run.repo_path.clone()),
148                Repository::Git,
149            );
150            let tree = identity
151                .worktree_path
152                .unwrap_or_else(|| run.repo_path.clone());
153            let scope = Scope::Worktree(repository.clone(), tree);
154            branches.insert(scope.clone(), identity.branch);
155            for scope in [Scope::All, Scope::Repository(repository), scope] {
156                paths
157                    .entry(scope.clone())
158                    .or_default()
159                    .insert(run.repo_path.clone());
160                badges.entry(scope).or_default().add(run);
161            }
162        }
163        if let Some(path) = initial {
164            let scope = self.initial_path(path, &paths);
165            if let Scope::Worktree(repository, _) = &scope {
166                paths
167                    .entry(Scope::Repository(repository.clone()))
168                    .or_default();
169                paths.entry(scope).or_default();
170            }
171        }
172        let repositories: Vec<_> = paths
173            .keys()
174            .filter_map(|scope| match scope {
175                Scope::Repository(repo) => Some(repo.clone()),
176                _ => None,
177            })
178            .collect();
179        for repository in &repositories {
180            if let Repository::Git(common) = repository {
181                for tree in self.trees(common) {
182                    let scope = Scope::Worktree(repository.clone(), tree.path);
183                    paths.entry(scope.clone()).or_default();
184                    branches.entry(scope).or_insert(tree.branch);
185                }
186            }
187        }
188        let mut rows = vec![CatalogRow {
189            scope: Scope::All,
190            label: "ALL".into(),
191            badge: badges.get(&Scope::All).cloned().unwrap_or_default(),
192            depth: 0,
193            fold: None,
194        }];
195        for repository in repositories {
196            let root = match &repository {
197                Repository::Git(path) => path.parent().unwrap_or(path),
198                Repository::Workspace(path) => path.as_path(),
199            };
200            let label = root.file_name().map_or_else(
201                || crate::platform::path_text(root),
202                |name| name.to_string_lossy().into_owned(),
203            );
204            // The repository row is the whole repository; there is no separate `ALL` row.
205            let scope = Scope::Repository(repository.clone());
206            rows.push(CatalogRow {
207                scope: scope.clone(),
208                label,
209                badge: badges.get(&scope).cloned().unwrap_or_default(),
210                depth: 0,
211                fold: None,
212            });
213            let mut folded = 0;
214            let expanded = self.expanded.contains(&repository);
215            for (scope, workspaces) in paths.iter().filter(
216                |(scope, _)| matches!(scope, Scope::Worktree(repo, _) if repo == &repository),
217            ) {
218                let Scope::Worktree(_, tree) = scope else {
219                    unreachable!("worktree filter")
220                };
221                if workspaces.is_empty() && !expanded && self.selected.as_ref() != Some(scope) {
222                    folded += 1;
223                    continue;
224                }
225                let branch = branches.get(scope).and_then(Option::as_deref);
226                let name = tree.file_name().map_or_else(
227                    || crate::platform::path_text(tree),
228                    |name| name.to_string_lossy().into_owned(),
229                );
230                let label = match (branch, &repository) {
231                    (Some(branch), _) if branch == name => branch.to_owned(),
232                    (Some(branch), _) => format!("{branch} ยท {name}"),
233                    (None, Repository::Git(_)) => format!("{name} (detached)"),
234                    (None, Repository::Workspace(_)) => format!("{name} (non-Git)"),
235                };
236                rows.push(CatalogRow {
237                    scope: scope.clone(),
238                    label,
239                    badge: badges.get(scope).cloned().unwrap_or_default(),
240                    depth: 1,
241                    fold: None,
242                });
243            }
244            let empty = paths
245                .iter()
246                .filter(|(scope, workspaces)| {
247                    matches!(scope, Scope::Worktree(repo, _) if repo == &repository)
248                        && workspaces.is_empty()
249                })
250                .count();
251            if folded > 0 || expanded && empty > 0 {
252                rows.push(CatalogRow {
253                    scope: scope.clone(),
254                    label: if expanded {
255                        format!("โˆ’ hide {empty} without Runs (Space)")
256                    } else {
257                        format!("+{folded} without Runs (Space)")
258                    },
259                    badge: Badge::default(),
260                    depth: 1,
261                    fold: Some(repository.clone()),
262                });
263            }
264        }
265        self.rows = rows;
266        self.paths = paths;
267    }
268    /// Navigation/fold changes reuse the last accepted database facts, not a catalog query.
269    pub fn rebuild(&mut self) {
270        let facts = self.facts.clone();
271        let initial = self.initial_path.clone();
272        self.update(&facts, initial.as_deref());
273    }
274    /// Show or hide a repository's worktrees without Runs; applied on the next update.
275    pub fn toggle(&mut self, repository: &Repository) {
276        if !self.expanded.remove(repository) {
277            self.expanded.insert(repository.clone());
278        }
279    }
280    /// The name the breadcrumb shows for `scope`: its row's label, else its folder name.
281    pub fn label(&self, scope: &Scope) -> String {
282        if *scope == Scope::All {
283            return "all repositories".into();
284        }
285        self.rows
286            .iter()
287            .find(|row| row.fold.is_none() && row.scope == *scope)
288            .map_or_else(
289                || match scope {
290                    Scope::Worktree(_, path) => path.file_name().map_or_else(
291                        || crate::platform::path_text(path),
292                        |name| name.to_string_lossy().into(),
293                    ),
294                    _ => "repository".into(),
295                },
296                |row| row.label.clone(),
297            )
298    }
299    pub fn paths(&self, scope: &Scope) -> Option<Vec<PathBuf>> {
300        match scope {
301            Scope::All => None,
302            _ => Some(
303                self.paths
304                    .get(scope)
305                    .map_or_else(Vec::new, |paths| paths.iter().cloned().collect()),
306            ),
307        }
308    }
309}