Skip to main content

path_cli/
artifact.rs

1//! [`ArtifactType`], the single enum naming the artifact sources the
2//! CLI operates over, plus `ArtifactRef` — an artifact's identity
3//! and stat-level fingerprint — and the stamp helpers that produce
4//! those fingerprints for sync and import provenance.
5
6/// The kind of artifact an operation ranges over. One enum, used
7/// everywhere a command names artifact sources (`p cache sync` types,
8/// `share`/`resume` `--harness` via the
9/// [`Harness`](crate::harness::Harness) layer, import cache-id
10/// prefixes); `name()` doubles as the manifest key and cache-id
11/// prefix. Git artifacts are recorded in the manifest when imported
12/// but are not *discoverable* — there is no machine-wide registry of
13/// repos to enumerate — so sync never re-derives them. Github and
14/// pathbase are absent on purpose: they are remote services, not
15/// local artifact sources.
16#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)]
17#[value(rename_all = "lower")]
18pub enum ArtifactType {
19    Claude,
20    Gemini,
21    Codex,
22    Opencode,
23    Cursor,
24    Pi,
25    Copilot,
26    Git,
27}
28
29impl ArtifactType {
30    /// Every artifact type, in presentation order.
31    pub(crate) const ALL: [ArtifactType; 8] = [
32        ArtifactType::Claude,
33        ArtifactType::Gemini,
34        ArtifactType::Codex,
35        ArtifactType::Opencode,
36        ArtifactType::Cursor,
37        ArtifactType::Pi,
38        ArtifactType::Copilot,
39        ArtifactType::Git,
40    ];
41
42    pub(crate) fn name(&self) -> &'static str {
43        match self {
44            ArtifactType::Claude => "claude",
45            ArtifactType::Gemini => "gemini",
46            ArtifactType::Codex => "codex",
47            ArtifactType::Opencode => "opencode",
48            ArtifactType::Cursor => "cursor",
49            ArtifactType::Pi => "pi",
50            ArtifactType::Copilot => "copilot",
51            ArtifactType::Git => "git",
52        }
53    }
54
55    /// Width of the provider-name column in picker rows: the length of
56    /// the longest `name()` ("opencode"). A test asserts they stay in
57    /// sync.
58    pub(crate) const NAME_COLUMN_WIDTH: usize = 8;
59
60    /// `name()` left-justified to [`Self::NAME_COLUMN_WIDTH`], so the
61    /// text after it starts at the same column on every picker row.
62    pub(crate) fn padded_name(&self) -> String {
63        format!("{:<width$}", self.name(), width = Self::NAME_COLUMN_WIDTH)
64    }
65
66    /// True when the underlying provider keys artifacts by a filesystem
67    /// path (the project directory). claude/gemini/pi: true.
68    /// codex/opencode/cursor: false (sessions store cwd per-row, not as
69    /// a directory key — cursor stores it as
70    /// `workspaceIdentifier.uri.fsPath` on each composer).
71    pub(crate) fn path_keyed(&self) -> bool {
72        matches!(
73            self,
74            ArtifactType::Claude | ArtifactType::Gemini | ArtifactType::Pi | ArtifactType::Git
75        )
76    }
77
78    pub(crate) fn parse(s: &str) -> Option<Self> {
79        <Self as clap::ValueEnum>::from_str(s, false).ok()
80    }
81}
82
83/// An artifact's identity plus the stat-level fingerprint of its
84/// source. Sync enumerates these for change detection (producing one
85/// never parses session bodies), and `p import`/`share` fill one as
86/// the provenance of each derived document so the write can be
87/// recorded in the manifest.
88#[derive(Debug, Clone)]
89pub(crate) struct ArtifactRef {
90    pub(crate) artifact_type: ArtifactType,
91    pub(crate) id: String,
92    /// Filesystem path the artifact is keyed under, for path-keyed
93    /// providers (the project directory; the repo for git).
94    pub(crate) path: Option<String>,
95    /// Source mtime (file providers) or updated-at (DB providers).
96    pub(crate) modified: Option<chrono::DateTime<chrono::Utc>>,
97    /// Source file size; `None` for DB-backed providers.
98    pub(crate) size: Option<u64>,
99}
100
101/// (mtime, size) of a file, both `None` when the stat fails.
102pub(crate) fn stat_stamp(
103    path: &std::path::Path,
104) -> (Option<chrono::DateTime<chrono::Utc>>, Option<u64>) {
105    match std::fs::metadata(path) {
106        Ok(md) => (
107            md.modified()
108                .ok()
109                .map(chrono::DateTime::<chrono::Utc>::from),
110            Some(md.len()),
111        ),
112        Err(_) => (None, None),
113    }
114}
115
116/// Stat-level fingerprint of a whole claude session chain: max mtime
117/// across the chain's segment files plus the sum of their sizes. Claude
118/// Code rotates to a new file on continuation (plan-mode exit, resume,
119/// fork) while the chain keeps the *first* segment's id — appends land
120/// in the newest segment, so statting the head file alone would freeze
121/// the fingerprint at the first rotation and sync would never see the
122/// later turns. The chain here is exactly the set of files
123/// `read_conversation` merges, so the fingerprint and the derived doc
124/// move in lockstep. The chain index is already built (and cached) by
125/// the `list_conversations` call every caller makes first.
126pub(crate) fn claude_chain_stamp(
127    mgr: &toolpath_claude::ClaudeConvo,
128    project: &str,
129    session: &str,
130) -> (Option<chrono::DateTime<chrono::Utc>>, Option<u64>) {
131    let segments = match mgr.session_chain(project, session) {
132        Ok(segments) if !segments.is_empty() => segments,
133        _ => vec![session.to_string()],
134    };
135    let mut modified: Option<chrono::DateTime<chrono::Utc>> = None;
136    let mut size: Option<u64> = None;
137    for segment in &segments {
138        let Ok(file) = mgr.resolver().conversation_file(project, segment) else {
139            continue;
140        };
141        let (m, s) = stat_stamp(&file);
142        if let Some(m) = m {
143            modified = Some(modified.map_or(m, |cur| cur.max(m)));
144        }
145        if let Some(s) = s {
146            size = Some(size.unwrap_or(0) + s);
147        }
148    }
149    (modified, size)
150}
151
152#[cfg(test)]
153mod type_tests {
154    use super::ArtifactType;
155
156    #[test]
157    fn names_are_distinct() {
158        let names: std::collections::HashSet<&str> =
159            ArtifactType::ALL.iter().map(|t| t.name()).collect();
160        assert_eq!(names.len(), ArtifactType::ALL.len());
161    }
162
163    #[test]
164    fn name_column_width_is_the_longest_name() {
165        let longest = ArtifactType::ALL
166            .iter()
167            .map(|t| t.name().len())
168            .max()
169            .unwrap();
170        assert_eq!(ArtifactType::NAME_COLUMN_WIDTH, longest);
171        for t in ArtifactType::ALL {
172            assert_eq!(t.padded_name().len(), ArtifactType::NAME_COLUMN_WIDTH);
173        }
174    }
175
176    #[test]
177    fn path_keyed_matches_design() {
178        assert!(ArtifactType::Claude.path_keyed());
179        assert!(ArtifactType::Gemini.path_keyed());
180        assert!(ArtifactType::Pi.path_keyed());
181        assert!(!ArtifactType::Codex.path_keyed());
182        assert!(!ArtifactType::Opencode.path_keyed());
183        assert!(!ArtifactType::Cursor.path_keyed());
184        assert!(ArtifactType::Git.path_keyed());
185    }
186
187    #[test]
188    fn parse_roundtrips_every_name() {
189        for t in ArtifactType::ALL {
190            assert_eq!(ArtifactType::parse(t.name()), Some(t));
191        }
192        assert_eq!(ArtifactType::parse("frobnicate"), None);
193    }
194}