use serde::{Deserialize, Serialize};
pub const MANIFEST_NAME: &str = "pack.toml";
pub const PACK_FORMAT_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub format_version: u32,
pub created_at: String,
pub source_root: String,
pub project_name: String,
pub lds_version: String,
pub stats: Stats,
#[serde(default)]
pub no_link_report_applied: Vec<String>,
#[serde(default)]
pub kept_over_secret: Vec<KeptOverSecret>,
#[serde(default)]
pub skipped_cache: Vec<CacheRecord>,
#[serde(default)]
pub skipped_secret: Vec<SkipRecord>,
#[serde(default)]
pub skipped_noise: Vec<SkipRecord>,
#[serde(default)]
pub symlinks: Vec<SymlinkRecord>,
#[serde(default)]
pub worktrees: Vec<WorktreeRecord>,
#[serde(default)]
pub worktree_of: Option<WorktreeOrigin>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Stats {
pub file_count: u64,
pub symlink_count: u64,
pub total_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeptOverSecret {
pub path: String,
pub keep_pattern: String,
pub secret_pattern: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkipRecord {
pub path: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheRecord {
pub path: String,
pub reason: String,
pub file_count: u64,
pub total_bytes: u64,
#[serde(default)]
pub secrets: Vec<SkipRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymlinkRecord {
pub path: String,
pub target: String,
pub outside_root: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeRecord {
pub name: String,
pub path: Option<String>,
pub source_path: String,
pub included: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeOrigin {
pub name: String,
pub admin_path: String,
pub parent_root: String,
}
impl Manifest {
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
toml::from_str(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Manifest {
Manifest {
format_version: PACK_FORMAT_VERSION,
created_at: "2026-08-10T00:00:00Z".to_string(),
source_root: "/tmp/proj".to_string(),
project_name: "proj".to_string(),
lds_version: "0.13.3".to_string(),
stats: Stats {
file_count: 3,
symlink_count: 1,
total_bytes: 42,
},
no_link_report_applied: vec![".zsh/**".to_string()],
kept_over_secret: vec![KeptOverSecret {
path: ".env.example".to_string(),
keep_pattern: ".env.example".to_string(),
secret_pattern: ".env.*".to_string(),
}],
skipped_cache: vec![CacheRecord {
path: "target".to_string(),
reason: "cache directory: target".to_string(),
file_count: 38104,
total_bytes: 4_200_000_000,
secrets: vec![SkipRecord {
path: "target/tmp/.npmrc".to_string(),
reason: "secret pattern: .npmrc".to_string(),
}],
}],
skipped_secret: vec![SkipRecord {
path: ".env".to_string(),
reason: "secret pattern: .env".to_string(),
}],
skipped_noise: vec![SkipRecord {
path: "sub/.DS_Store".to_string(),
reason: "os debris: .DS_Store".to_string(),
}],
symlinks: vec![SymlinkRecord {
path: "link".to_string(),
target: "/elsewhere".to_string(),
outside_root: true,
}],
worktrees: vec![WorktreeRecord {
name: "wt".to_string(),
path: Some(".worktrees/wt".to_string()),
source_path: "/tmp/proj/.worktrees/wt".to_string(),
included: true,
}],
worktree_of: None,
}
}
#[test]
fn test_manifest_round_trip() {
let original = sample();
let text = original.to_toml().expect("serialize should succeed");
let parsed = Manifest::from_toml(&text).expect("parse should succeed");
assert_eq!(parsed.format_version, original.format_version);
assert_eq!(parsed.source_root, original.source_root);
assert_eq!(parsed.stats.file_count, 3);
assert_eq!(parsed.no_link_report_applied, vec![".zsh/**".to_string()]);
assert_eq!(parsed.kept_over_secret[0].path, ".env.example");
assert_eq!(parsed.kept_over_secret[0].secret_pattern, ".env.*");
assert_eq!(parsed.skipped_cache[0].file_count, 38104);
assert_eq!(parsed.skipped_cache[0].total_bytes, 4_200_000_000);
assert_eq!(parsed.skipped_cache[0].secrets[0].path, "target/tmp/.npmrc");
assert_eq!(parsed.skipped_noise[0].path, "sub/.DS_Store");
assert_eq!(parsed.skipped_secret.len(), 1);
assert_eq!(parsed.symlinks[0].target, "/elsewhere");
assert_eq!(parsed.worktrees[0].name, "wt");
}
#[test]
fn test_manifest_parses_without_optional_sections() {
let text = r#"
format_version = 1
created_at = "2026-08-10T00:00:00Z"
source_root = "/tmp/proj"
project_name = "proj"
lds_version = "0.13.3"
[stats]
file_count = 0
symlink_count = 0
total_bytes = 0
"#;
let parsed = Manifest::from_toml(text).expect("minimal manifest should parse");
assert!(parsed.skipped_cache.is_empty());
assert!(parsed.skipped_noise.is_empty());
assert!(parsed.skipped_secret.is_empty());
assert!(parsed.symlinks.is_empty());
assert!(parsed.worktrees.is_empty());
assert!(
parsed.no_link_report_applied.is_empty(),
"an operator who suppressed nothing gets no suppression record"
);
assert!(parsed.kept_over_secret.is_empty());
}
#[test]
fn test_manifest_reads_legacy_v1_claude_section() {
let text = r#"
format_version = 1
created_at = "2026-08-10T00:00:00Z"
source_root = "/tmp/proj"
project_name = "proj"
lds_version = "0.14.0"
[stats]
file_count = 0
symlink_count = 0
total_bytes = 0
[claude]
present = true
symlink_count = 154
link_roots = ["/mnt/links/shared"]
"#;
let parsed = Manifest::from_toml(text).expect("a v1 manifest must still parse");
assert_eq!(parsed.format_version, 1);
assert!(
parsed.no_link_report_applied.is_empty(),
"the legacy section is dropped, not translated into a suppression"
);
}
#[test]
fn test_worktree_record_allows_absent_path() {
let mut m = sample();
m.worktrees[0].path = None;
m.worktrees[0].included = false;
let text = m.to_toml().expect("serialize should succeed");
let parsed = Manifest::from_toml(&text).expect("parse should succeed");
assert!(parsed.worktrees[0].path.is_none());
assert!(!parsed.worktrees[0].included);
}
}