lds_pack/manifest.rs
1//! `pack.toml` — the manifest carried at the head of every `.pack` archive.
2//!
3//! The manifest is what makes a pack *inspectable without unpacking*: it is
4//! written as the first entry of the tar stream, so [`crate::inspect`] can stop
5//! reading as soon as it has been decoded.
6//!
7//! It records three classes of information:
8//!
9//! 1. **provenance** — where the pack came from and when.
10//! 2. **what was deliberately left out** — cache directories (regenerable) and
11//! secrets (never packed; carrying those is the operator's own business).
12//! 3. **what needs attention on restore** — symlinks that point outside the
13//! project, and worktrees whose absolute `gitdir` pointers were rewritten.
14
15use serde::{Deserialize, Serialize};
16
17/// File name of the manifest inside the archive.
18pub const MANIFEST_NAME: &str = "pack.toml";
19
20/// Current pack format version.
21///
22/// Bumped when the on-disk layout changes in a way older readers cannot
23/// interpret. [`crate::restore`] refuses archives newer than this.
24pub const PACK_FORMAT_VERSION: u32 = 1;
25
26/// Top-level manifest written to `pack.toml`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Manifest {
29 /// Pack format version (see [`PACK_FORMAT_VERSION`]).
30 pub format_version: u32,
31 /// RFC 3339 timestamp of when the pack was created.
32 pub created_at: String,
33 /// Absolute path of the project root the pack was created from.
34 ///
35 /// Restoring into a different path is expected and supported; this field
36 /// exists so worktree pointer rewriting can compute the delta, and so a
37 /// human reading the manifest can tell where the pack came from.
38 pub source_root: String,
39 /// Directory name of the source root (used as the default restore name).
40 pub project_name: String,
41 /// Version of the `lds` binary that produced the pack.
42 pub lds_version: String,
43 /// Aggregate counts for the payload.
44 pub stats: Stats,
45 /// The `.claude/` directory, tracked separately from ordinary content.
46 pub claude: ClaudeInfo,
47 /// Cache directories that were skipped because they can be regenerated.
48 #[serde(default)]
49 pub skipped_cache: Vec<SkipRecord>,
50 /// Secret-looking files that were skipped and reported instead of packed.
51 #[serde(default)]
52 pub skipped_secret: Vec<SkipRecord>,
53 /// Symlinks found outside `.claude/`, recorded verbatim for the report.
54 #[serde(default)]
55 pub symlinks: Vec<SymlinkRecord>,
56 /// Registered git worktrees whose pointer files need rewriting on restore.
57 #[serde(default)]
58 pub worktrees: Vec<WorktreeRecord>,
59}
60
61/// Aggregate payload counts, filled in as the archive is written.
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct Stats {
64 /// Number of regular files packed.
65 pub file_count: u64,
66 /// Number of symlinks packed (stored as links, never dereferenced).
67 pub symlink_count: u64,
68 /// Sum of regular-file sizes in bytes, before compression.
69 pub total_bytes: u64,
70}
71
72/// State of the `.claude/` directory.
73///
74/// `.claude/` is handled as its own layer: it is packed verbatim, symlinks and
75/// all, and its links are *not* enumerated individually. In a profile-managed
76/// setup it is commonly a tree of a hundred-plus links into a profiles
77/// repository, and listing each one would bury the rest of the manifest while
78/// telling the reader nothing they can act on per entry.
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct ClaudeInfo {
81 /// Whether a `.claude/` directory was present at the source root.
82 pub present: bool,
83 /// How many symlinks live under `.claude/` (aggregate only).
84 pub symlink_count: u64,
85 /// Distinct roots those symlinks point into, deduplicated.
86 ///
87 /// Enough for a restore-side reader to see "these links want
88 /// `<profiles-root>` to exist" without a per-file list.
89 #[serde(default)]
90 pub link_roots: Vec<String>,
91}
92
93/// A path that was left out of the pack, with the reason why.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SkipRecord {
96 /// Path relative to the project root.
97 pub path: String,
98 /// Why it was skipped (matched pattern or rule name).
99 pub reason: String,
100}
101
102/// A symlink encountered outside `.claude/`.
103///
104/// Symlinks are packed as links, never dereferenced — following them would
105/// drag unrelated trees (and whatever they contain) into the archive. The
106/// record exists so restore can report what is dangling rather than silently
107/// producing a broken tree.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SymlinkRecord {
110 /// Path of the link itself, relative to the project root.
111 pub path: String,
112 /// Raw link target, exactly as stored on disk.
113 pub target: String,
114 /// Whether the target resolves outside the project root.
115 ///
116 /// Links that stay inside the root travel fine; only the ones pointing out
117 /// of it can dangle after a restore somewhere else.
118 pub outside_root: bool,
119}
120
121/// A registered git worktree carried in the pack.
122///
123/// Both halves of a worktree's wiring are absolute paths — `.git/worktrees/<name>/gitdir`
124/// points at the worktree's `.git` file, and that `.git` file points back at the
125/// admin directory. A plain extract leaves both aimed at the machine the pack
126/// came from, so restore rewrites them.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct WorktreeRecord {
129 /// Worktree name (the directory name under `.git/worktrees/`).
130 pub name: String,
131 /// Worktree location relative to the project root.
132 ///
133 /// `None` when the worktree lives outside the root, in which case its
134 /// contents are not in the pack and restore only reports it.
135 pub path: Option<String>,
136 /// Original absolute path of the worktree, as recorded at pack time.
137 pub source_path: String,
138 /// Whether the worktree's contents were included in the payload.
139 pub included: bool,
140}
141
142impl Manifest {
143 /// Serialize to TOML for embedding as the archive's first entry.
144 ///
145 /// # Errors
146 ///
147 /// Returns [`toml::ser::Error`] if serialization fails, which for this
148 /// schema means a bug rather than bad input.
149 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
150 toml::to_string_pretty(self)
151 }
152
153 /// Parse a manifest from TOML text.
154 ///
155 /// # Errors
156 ///
157 /// Returns [`toml::de::Error`] if the text is not a valid manifest.
158 pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
159 toml::from_str(text)
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn sample() -> Manifest {
168 Manifest {
169 format_version: PACK_FORMAT_VERSION,
170 created_at: "2026-08-10T00:00:00Z".to_string(),
171 source_root: "/tmp/proj".to_string(),
172 project_name: "proj".to_string(),
173 lds_version: "0.13.3".to_string(),
174 stats: Stats {
175 file_count: 3,
176 symlink_count: 1,
177 total_bytes: 42,
178 },
179 claude: ClaudeInfo {
180 present: true,
181 symlink_count: 154,
182 link_roots: vec!["/home/u/.config/profiles".to_string()],
183 },
184 skipped_cache: vec![SkipRecord {
185 path: "target".to_string(),
186 reason: "cache directory".to_string(),
187 }],
188 skipped_secret: vec![SkipRecord {
189 path: ".env".to_string(),
190 reason: "secret pattern: .env".to_string(),
191 }],
192 symlinks: vec![SymlinkRecord {
193 path: "link".to_string(),
194 target: "/elsewhere".to_string(),
195 outside_root: true,
196 }],
197 worktrees: vec![WorktreeRecord {
198 name: "wt".to_string(),
199 path: Some(".worktrees/wt".to_string()),
200 source_path: "/tmp/proj/.worktrees/wt".to_string(),
201 included: true,
202 }],
203 }
204 }
205
206 /// A manifest survives a serialize / parse round trip with every field intact.
207 #[test]
208 fn test_manifest_round_trip() {
209 let original = sample();
210 let text = original.to_toml().expect("serialize should succeed");
211 let parsed = Manifest::from_toml(&text).expect("parse should succeed");
212
213 assert_eq!(parsed.format_version, original.format_version);
214 assert_eq!(parsed.source_root, original.source_root);
215 assert_eq!(parsed.stats.file_count, 3);
216 assert_eq!(parsed.claude.symlink_count, 154);
217 assert_eq!(parsed.skipped_secret.len(), 1);
218 assert_eq!(parsed.symlinks[0].target, "/elsewhere");
219 assert_eq!(parsed.worktrees[0].name, "wt");
220 }
221
222 /// Optional list sections may be absent entirely; they default to empty.
223 #[test]
224 fn test_manifest_parses_without_optional_sections() {
225 let text = r#"
226format_version = 1
227created_at = "2026-08-10T00:00:00Z"
228source_root = "/tmp/proj"
229project_name = "proj"
230lds_version = "0.13.3"
231
232[stats]
233file_count = 0
234symlink_count = 0
235total_bytes = 0
236
237[claude]
238present = false
239symlink_count = 0
240"#;
241 let parsed = Manifest::from_toml(text).expect("minimal manifest should parse");
242 assert!(parsed.skipped_cache.is_empty());
243 assert!(parsed.skipped_secret.is_empty());
244 assert!(parsed.symlinks.is_empty());
245 assert!(parsed.worktrees.is_empty());
246 assert!(parsed.claude.link_roots.is_empty());
247 }
248
249 /// A worktree living outside the root carries no relative path.
250 #[test]
251 fn test_worktree_record_allows_absent_path() {
252 let mut m = sample();
253 m.worktrees[0].path = None;
254 m.worktrees[0].included = false;
255 let text = m.to_toml().expect("serialize should succeed");
256 let parsed = Manifest::from_toml(&text).expect("parse should succeed");
257 assert!(parsed.worktrees[0].path.is_none());
258 assert!(!parsed.worktrees[0].included);
259 }
260}