artifactize-core 0.21.1

The library behind artifactize: Artifacts, evals, reviews, the monitor and review models, and the shared operations every interface calls.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! The live branches of every project with Runs on this machine, as the desktop's pane lists
//! them: each Git worktree that exists now with the branch it has checked out (a detached one by
//! its folder), and each folder outside Git that exists now and holds declarations. A branch's
//! Runs are its project's Runs on that branch, from whichever folder they ran in; who ran them
//! makes no branch of its own. The terminal monitor keeps its own catalog of scopes.
mod current;
mod loader;
mod query;
mod starred;
mod waiting;

#[cfg(test)]
mod tests;

pub use current::{Current, CurrentArtifact, CurrentEdge, CurrentEval, Plan, scan};
pub use loader::{Input, Loader, Opened, Snapshot};
pub use query::{DEFAULT, Query, Sort, complete, show};
pub use starred::{star, star_artifact, starred, starred_artifacts};
pub use waiting::{Waiting, waiting};

use crate::{
    repository::{self, Identity, Worktree},
    store::CatalogRun,
    types::{RunId, RunStatus, Timestamp},
};
use serde::{Deserialize, Serialize};
use std::{
    collections::{BTreeMap, BTreeSet},
    path::{Path, PathBuf},
};

/// A branch's name as Git accepts one: not empty, and none of the characters or sequences
/// `git check-ref-format --branch` refuses. Made at the Git and serde boundaries, so no other
/// name becomes a branch's key.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct BranchName(String);

impl BranchName {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl TryFrom<String> for BranchName {
    type Error = String;

    fn try_from(name: String) -> Result<Self, String> {
        // Each `/`-separated part is a name of its own: not empty, not starting with `.`, not
        // ending with `.lock`.
        let refused = name.starts_with('-')
            || name.ends_with('.')
            || name == "@"
            || name.contains("..")
            || name.contains("@{")
            || name
                .split('/')
                .any(|part| part.is_empty() || part.starts_with('.') || part.ends_with(".lock"))
            || name.chars().any(|c| {
                c.is_control() || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\')
            });
        if refused {
            Err(format!("not a branch name: {name:?}"))
        } else {
            Ok(Self(name))
        }
    }
}

impl From<BranchName> for String {
    fn from(name: BranchName) -> Self {
        name.0
    }
}

impl std::fmt::Display for BranchName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// How a Git worktree has its project checked out.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Head {
    Branch(BranchName),
    /// A detached worktree, known by its folder.
    Detached(#[serde(with = "crate::platform::path_serde")] PathBuf),
}

/// Where a branch lives: a Git repository's checked-out branch, by the repository's common
/// directory, or a folder outside Git.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Place {
    Git {
        #[serde(with = "crate::platform::path_serde")]
        common: PathBuf,
        head: Head,
    },
    Folder(#[serde(with = "crate::platform::path_serde")] PathBuf),
}

impl Place {
    /// The project the branch belongs to: every branch of a Git repository shares one.
    pub fn project(&self) -> Project {
        match self {
            Self::Git { common, .. } => Project::Git(common.clone()),
            Self::Folder(path) => Project::Folder(path.clone()),
        }
    }
}

/// A project: a Git repository, by its common directory, or a folder outside Git.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Project {
    Git(#[serde(with = "crate::platform::path_serde")] PathBuf),
    Folder(#[serde(with = "crate::platform::path_serde")] PathBuf),
}

/// One Run of a branch as its chip shows it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chip {
    pub id: RunId,
    pub status: RunStatus,
    pub created_at: Timestamp,
    pub completed_at: Option<Timestamp>,
    /// What the requester said it is working on (`verify --comment`).
    pub comment: Option<crate::types::RunComment>,
}

/// A live branch and its Runs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
    pub place: Place,
    /// The project's name: its repository's or folder's.
    pub project: String,
    /// The branch, a detached worktree's folder name, or empty for a folder outside Git.
    pub name: String,
    /// Where its Current is scanned and its Artifacts' tools run: the worktree (or folder) as it
    /// is now, at the workspace its newest Run used (or the app was started for) within it.
    pub workspace: PathBuf,
    /// Its Runs, oldest first.
    pub runs: Vec<Chip>,
}

impl Branch {
    /// The Run in progress a branch's timeline starts with: the newest Run, while it runs.
    pub fn running(&self) -> Option<&Chip> {
        self.runs.last().filter(|chip| chip.status.is_open())
    }

    /// When its newest Run started.
    pub fn latest(&self) -> Option<Timestamp> {
        self.runs.last().map(|chip| chip.created_at)
    }
}

/// Which project and branch a saved Run ran on, by name, for every Run whether or not its
/// branch still lives: what a waiting review of it is listed with.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Label {
    pub project: String,
    pub branch: String,
}

pub type Labels = BTreeMap<RunId, Label>;

/// Cached Git facts. Identities of old Run records without a saved identity are asked of Git
/// once; which worktrees exist is asked again on every [`Discovery::rescan`].
#[derive(Default)]
pub struct Discovery {
    identities: BTreeMap<PathBuf, Identity>,
    trees: BTreeMap<PathBuf, Vec<Worktree>>,
}

impl Discovery {
    /// Ask Git again which worktrees exist, on the next listing.
    pub fn rescan(&mut self) {
        self.trees.clear();
    }

    fn identify(&mut self, path: &Path) -> Identity {
        self.identities
            .entry(path.to_path_buf())
            .or_insert_with(|| repository::identify(path))
            .clone()
    }

    fn worktrees(&mut self, common: &Path) -> Vec<Worktree> {
        self.trees
            .entry(common.to_path_buf())
            .or_insert_with(|| {
                repository::worktrees(common)
                    .into_iter()
                    .map(|tree| Worktree {
                        // Saved identities are canonical; a detached head is matched by path.
                        path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
                        branch: tree.branch,
                    })
                    .collect()
            })
            .clone()
    }
}

/// Where `identity` (of the workspace `repo`) lives, and `repo` relative to its worktree.
/// The head of the worktree at `tree` with `branch` checked out, or detached without one. A
/// name Git reports that is no branch name is known by the worktree's folder instead.
fn head(branch: Option<String>, tree: &Path) -> Head {
    branch
        .and_then(|name| BranchName::try_from(name).ok())
        .map_or_else(|| Head::Detached(tree.to_path_buf()), Head::Branch)
}

fn place_of(identity: &Identity, repo: &Path) -> (Place, PathBuf) {
    match (&identity.common_dir, &identity.worktree_path) {
        (Some(common), Some(tree)) => {
            let head = head(identity.branch.clone(), tree);
            let relative = repo
                .strip_prefix(tree)
                .map(Path::to_path_buf)
                .unwrap_or_default();
            (
                Place::Git {
                    common: common.clone(),
                    head,
                },
                relative,
            )
        }
        _ => (Place::Folder(repo.to_path_buf()), PathBuf::new()),
    }
}

fn folder_name(path: &Path) -> String {
    path.file_name().map_or_else(
        || crate::platform::path_text(path),
        |name| name.to_string_lossy().into_owned(),
    )
}

/// The project's name: a repository's main folder (the parent of its `.git`), a bare
/// repository's own folder without `.git`, or the folder outside Git.
fn project_name(place: &Place) -> String {
    match place {
        Place::Git { common, .. } if common.file_name().is_some_and(|name| name == ".git") => {
            folder_name(common.parent().unwrap_or(common))
        }
        Place::Git { common, .. } => {
            let name = folder_name(common);
            name.strip_suffix(".git")
                .map_or(name.clone(), str::to_owned)
        }
        Place::Folder(path) => folder_name(path),
    }
}

fn branch_name(place: &Place) -> String {
    match place {
        Place::Git {
            head: Head::Branch(branch),
            ..
        } => branch.to_string(),
        Place::Git {
            head: Head::Detached(tree),
            ..
        } => folder_name(tree),
        Place::Folder(_) => String::new(),
    }
}

fn label(place: &Place) -> Label {
    Label {
        project: project_name(place),
        branch: branch_name(place),
    }
}

/// What the listing of the branches found.
pub struct Listing {
    /// The live branches, the most recent Run first.
    pub branches: Vec<Branch>,
    pub labels: Labels,
    /// The branch of the folder the app was started for, when it lives.
    pub launch: Option<Place>,
}

/// A branch's Runs so far, and when its newest started with its workspace in the worktree.
type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);

/// The live branches of the projects of `runs`, with their Runs, and the branch of `launch`
/// (the folder the app was started for) even before its project has a Run.
pub fn branches(runs: &[CatalogRun], launch: Option<&Path>, discovery: &mut Discovery) -> Listing {
    let mut grouped: BTreeMap<Place, Grouped> = BTreeMap::new();
    let mut labels = Labels::new();
    for run in runs {
        let identity =
            if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
                run.repository.clone()
            } else {
                discovery.identify(&run.repo_path)
            };
        let (place, relative) = place_of(&identity, &run.repo_path);
        labels.insert(run.id.clone(), label(&place));
        let (chips, newest) = grouped.entry(place).or_default();
        chips.push(Chip {
            id: run.id.clone(),
            status: run.status,
            created_at: run.created_at,
            completed_at: run.completed_at,
            comment: run.comment.clone(),
        });
        if newest.as_ref().is_none_or(|(at, _)| run.created_at >= *at) {
            *newest = Some((run.created_at, relative));
        }
    }
    let launched = launch.map(|path| {
        let (place, _) = place_of(&discovery.identify(path), path);
        (place, path.to_path_buf())
    });
    let commons: BTreeSet<PathBuf> = grouped
        .keys()
        .chain(launched.iter().map(|(place, _)| place))
        .filter_map(|place| match place {
            Place::Git { common, .. } => Some(common.clone()),
            Place::Folder(_) => None,
        })
        .collect();
    let mut live: Vec<(Place, PathBuf)> = commons
        .into_iter()
        .flat_map(|common| {
            discovery.worktrees(&common).into_iter().map(move |tree| {
                let head = head(tree.branch, &tree.path);
                (
                    Place::Git {
                        common: common.clone(),
                        head,
                    },
                    tree.path,
                )
            })
        })
        .collect();
    let folders: BTreeSet<&PathBuf> = grouped
        .keys()
        .chain(launched.iter().map(|(place, _)| place))
        .filter_map(|place| match place {
            Place::Folder(path) => Some(path),
            Place::Git { .. } => None,
        })
        .collect();
    // A Run's folder is live while it still holds declarations; the folder the app was started
    // for is live while it exists, as the CLI takes any folder as `--repo`, its Artifacts
    // perhaps only in subfolders.
    let started = |path: &PathBuf| {
        launched
            .as_ref()
            .is_some_and(|(place, _)| matches!(place, Place::Folder(folder) if folder == path))
    };
    live.extend(
        folders
            .into_iter()
            .filter(|path| {
                path.is_dir()
                    && (started(path)
                        || crate::workspace::has_artifact_marker(path).unwrap_or(false))
            })
            .map(|path| (Place::Folder(path.clone()), path.clone())),
    );
    let mut branches: Vec<Branch> = live
        .into_iter()
        .map(|(place, tree)| {
            let (mut runs, newest) = grouped.remove(&place).unwrap_or_default();
            runs.sort_by(|a, b| (a.created_at, &a.id).cmp(&(b.created_at, &b.id)));
            let workspace = match &launched {
                Some((launch, path)) if *launch == place => path.clone(),
                _ => newest
                    .map(|(_, relative)| tree.join(relative))
                    .filter(|path| path.is_dir())
                    .unwrap_or(tree),
            };
            let Label { project, branch } = label(&place);
            Branch {
                place,
                project,
                name: branch,
                workspace,
                runs,
            }
        })
        .collect();
    query::order(&mut branches, Sort::Recent);
    let launch = launched
        .map(|(place, _)| place)
        .filter(|place| branches.iter().any(|branch| &branch.place == place));
    Listing {
        branches,
        labels,
        launch,
    }
}