#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)]
#[value(rename_all = "lower")]
pub enum ArtifactType {
Claude,
Gemini,
Codex,
Opencode,
Cursor,
Pi,
Copilot,
Git,
}
impl ArtifactType {
pub(crate) const ALL: [ArtifactType; 8] = [
ArtifactType::Claude,
ArtifactType::Gemini,
ArtifactType::Codex,
ArtifactType::Opencode,
ArtifactType::Cursor,
ArtifactType::Pi,
ArtifactType::Copilot,
ArtifactType::Git,
];
pub(crate) fn name(&self) -> &'static str {
match self {
ArtifactType::Claude => "claude",
ArtifactType::Gemini => "gemini",
ArtifactType::Codex => "codex",
ArtifactType::Opencode => "opencode",
ArtifactType::Cursor => "cursor",
ArtifactType::Pi => "pi",
ArtifactType::Copilot => "copilot",
ArtifactType::Git => "git",
}
}
pub(crate) const NAME_COLUMN_WIDTH: usize = 8;
pub(crate) fn padded_name(&self) -> String {
format!("{:<width$}", self.name(), width = Self::NAME_COLUMN_WIDTH)
}
pub(crate) fn path_keyed(&self) -> bool {
matches!(
self,
ArtifactType::Claude | ArtifactType::Gemini | ArtifactType::Pi | ArtifactType::Git
)
}
pub(crate) fn parse(s: &str) -> Option<Self> {
<Self as clap::ValueEnum>::from_str(s, false).ok()
}
}
#[derive(Debug, Clone)]
pub(crate) struct ArtifactRef {
pub(crate) artifact_type: ArtifactType,
pub(crate) id: String,
pub(crate) path: Option<String>,
pub(crate) modified: Option<chrono::DateTime<chrono::Utc>>,
pub(crate) size: Option<u64>,
}
pub(crate) fn stat_stamp(
path: &std::path::Path,
) -> (Option<chrono::DateTime<chrono::Utc>>, Option<u64>) {
match std::fs::metadata(path) {
Ok(md) => (
md.modified()
.ok()
.map(chrono::DateTime::<chrono::Utc>::from),
Some(md.len()),
),
Err(_) => (None, None),
}
}
pub(crate) fn claude_chain_stamp(
mgr: &toolpath_claude::ClaudeConvo,
project: &str,
session: &str,
) -> (Option<chrono::DateTime<chrono::Utc>>, Option<u64>) {
let segments = match mgr.session_chain(project, session) {
Ok(segments) if !segments.is_empty() => segments,
_ => vec![session.to_string()],
};
let mut modified: Option<chrono::DateTime<chrono::Utc>> = None;
let mut size: Option<u64> = None;
for segment in &segments {
let Ok(file) = mgr.resolver().conversation_file(project, segment) else {
continue;
};
let (m, s) = stat_stamp(&file);
if let Some(m) = m {
modified = Some(modified.map_or(m, |cur| cur.max(m)));
}
if let Some(s) = s {
size = Some(size.unwrap_or(0) + s);
}
}
(modified, size)
}
#[cfg(test)]
mod type_tests {
use super::ArtifactType;
#[test]
fn names_are_distinct() {
let names: std::collections::HashSet<&str> =
ArtifactType::ALL.iter().map(|t| t.name()).collect();
assert_eq!(names.len(), ArtifactType::ALL.len());
}
#[test]
fn name_column_width_is_the_longest_name() {
let longest = ArtifactType::ALL
.iter()
.map(|t| t.name().len())
.max()
.unwrap();
assert_eq!(ArtifactType::NAME_COLUMN_WIDTH, longest);
for t in ArtifactType::ALL {
assert_eq!(t.padded_name().len(), ArtifactType::NAME_COLUMN_WIDTH);
}
}
#[test]
fn path_keyed_matches_design() {
assert!(ArtifactType::Claude.path_keyed());
assert!(ArtifactType::Gemini.path_keyed());
assert!(ArtifactType::Pi.path_keyed());
assert!(!ArtifactType::Codex.path_keyed());
assert!(!ArtifactType::Opencode.path_keyed());
assert!(!ArtifactType::Cursor.path_keyed());
assert!(ArtifactType::Git.path_keyed());
}
#[test]
fn parse_roundtrips_every_name() {
for t in ArtifactType::ALL {
assert_eq!(ArtifactType::parse(t.name()), Some(t));
}
assert_eq!(ArtifactType::parse("frobnicate"), None);
}
}