1#[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 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 pub(crate) const NAME_COLUMN_WIDTH: usize = 8;
59
60 pub(crate) fn padded_name(&self) -> String {
63 format!("{:<width$}", self.name(), width = Self::NAME_COLUMN_WIDTH)
64 }
65
66 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#[derive(Debug, Clone)]
89pub(crate) struct ArtifactRef {
90 pub(crate) artifact_type: ArtifactType,
91 pub(crate) id: String,
92 pub(crate) path: Option<String>,
95 pub(crate) modified: Option<chrono::DateTime<chrono::Utc>>,
97 pub(crate) size: Option<u64>,
99}
100
101pub(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
116pub(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}