Skip to main content

majordomus_cli/pack/
mod.rs

1//! The source pack: the tracked tree as a few text files a language model's file search can
2//! index, with nothing in it that is not source.
3//!
4//! `majordomus archive` (the shell tool) hands a reader a zip. A chat model's project files
5//! are indexed one text file at a time and a zip is not text, so a model that is given the
6//! archive can unpack it in a sandbox but cannot search it. The pack is the same selection,
7//! written as Markdown shards that each stay under a token budget, with an index that
8//! orients the reader and a manifest that lets [`verify`] prove, afterwards, that what
9//! leaves the machine is what was selected and nothing else.
10//!
11//! # What is selected
12//!
13//! The profiles are `share/archive.yaml`'s: one declaration of what a snapshot is for, read
14//! by the shell's archive and by this module alike. The file set is the git index and the
15//! content is the index's blobs, never the working tree, so an ignored file, a build output,
16//! a cache and the local half of the AI layer cannot travel by construction. On top of that
17//! a profile that sets `artifacts: drop` refuses, whatever the index holds:
18//!
19//! - a gitlink (mode `160000`): a nested repository or worktree whose content is not here;
20//! - a symbolic link (mode `120000`): its content is a path on the machine that wrote it;
21//! - a path matching `artifact_paths` (`target/`, `node_modules/`, a `-wt/` worktree
22//!   container, minified bundles, ...): build output that was committed by accident;
23//! - content that is not UTF-8 text or holds a NUL byte: a binary whatever its extension.
24//!
25//! An `include` re-admits what `exclude` or `derived: drop` left out, never one of these:
26//! a list that could re-admit a binary is a list that eventually will.
27//!
28//! # What is refused
29//!
30//! The plan is a verdict, not only a selection. A single file over the shard budget, more
31//! shards than the profile allows, a selected file that names this machine (the checkout's
32//! own absolute path or the home directory of the account packing it), and an empty
33//! selection are findings, and a pack with a finding is not built.
34//!
35//! The content is carried exactly as committed and never rewritten. A pack holds nothing
36//! the tracked tree does not, and what the tree may hold is decided where it is committed
37//! (`scripts/ci/no-machine-paths`, the publication checks). Credential shapes are therefore
38//! not redacted here: measured on this repository, the published-text table matches `sk-`
39//! inside ordinary words (`task-in-progress-…`), so redacting would hand the reader prose
40//! that was never written, and a digest of content that was never committed. The one leak
41//! that can arise at packing time and not at commit time is the machine doing the packing,
42//! which is what is refused, with no false positive by construction.
43//!
44//! ```
45//! use majordomus_cli::pack::{DropReason, fence_for};
46//! assert_eq!(DropReason::Worktree.as_str(), "worktree");
47//! // a fence is always longer than the longest run of backticks in what it holds
48//! assert_eq!(fence_for("no ticks"), "```");
49//! assert_eq!(fence_for("a ```` b"), "`````");
50//! ```
51
52mod shard;
53
54use std::collections::{BTreeMap, BTreeSet};
55use std::io::Write;
56use std::path::{Path, PathBuf};
57use std::process::Stdio;
58
59use schemars::JsonSchema;
60use serde::{Deserialize, Serialize};
61
62use crate::discovery::glob::Glob;
63use crate::order::{canonical, OrderKey, Ordered};
64
65pub use shard::{fence_for, verify, PackVerdict};
66
67/// The profiles file of the tool distribution, relative to the share directory.
68pub const PROFILES: &str = "archive.yaml";
69/// A repository's own profiles, relative to its root. A profile declared there under an id
70/// the distribution ships replaces the shipped one whole, as the shell's archive reads it;
71/// the default and the two lists are the distribution's.
72pub const OVERLAY: &str = ".ai/repo/archive.yaml";
73/// The schema word of a pack's manifest.
74pub const MANIFEST_SCHEMA: &str = "majordomus.pack/v1";
75/// The manifest's file name inside a pack.
76pub const MANIFEST: &str = "pack.json";
77/// The orientation document's file name inside a pack; it is shard zero.
78pub const INDEX: &str = "00-INDEX.md";
79/// The share of the token budget a shard is filled to, in percent. The rest absorbs the
80/// difference between counting each file's block and counting the shard it lands in.
81const FILL_PERCENT: u64 = 95;
82/// The paths that name the machine a pack is built on: the checkout itself and the home
83/// directory of the account, each with a trailing separator so that a sibling with the
84/// same prefix is not one.
85///
86/// A path has more than one spelling — the one a shell's `$PWD` prints and the one the
87/// file system resolves it to, and on macOS `/var` and `/tmp` are `/private/var` and
88/// `/private/tmp` — so each is listed in every spelling it has.
89pub(crate) fn machine_paths(root: &Path) -> Vec<String> {
90    machine_paths_for(root, std::env::var_os("HOME").map(PathBuf::from))
91}
92
93/// [`machine_paths`] for the home directory `home`: none, `/`, and a hosted runner's home
94/// name no person's machine and add nothing to the checkout.
95fn machine_paths_for(root: &Path, home: Option<PathBuf>) -> Vec<String> {
96    let mut bases = vec![root.to_path_buf()];
97    bases.extend(home.filter(|h| h.as_os_str().len() > 1 && !is_runner_home(h)));
98    let mut out = BTreeSet::new();
99    for base in bases {
100        let resolved = std::fs::canonicalize(&base).unwrap_or_else(|_| base.clone());
101        for p in [base, resolved] {
102            let s = p.display().to_string();
103            if let Some(short) = s.strip_prefix("/private") {
104                if short.starts_with('/') {
105                    out.insert(format!("{short}/"));
106                }
107            }
108            out.insert(format!("{s}/"));
109        }
110    }
111    out.into_iter().collect()
112}
113
114/// Why a tracked file is not in the pack. Every reason but `derived` and `excluded` is
115/// final: no `include` of a profile re-admits a link, a gitlink, an artifact or a binary.
116///
117/// ```
118/// use majordomus_cli::pack::DropReason;
119/// assert_eq!(DropReason::Binary.as_str(), "binary");
120/// let json = serde_json::to_string(&DropReason::Artifact).unwrap();
121/// assert_eq!(json, "\"artifact\"");
122/// ```
123#[derive(
124    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
125)]
126#[serde(rename_all = "snake_case")]
127pub enum DropReason {
128    /// A gitlink: a nested repository or worktree whose content is not in this index.
129    Worktree,
130    /// A symbolic link, whose content is a path on the machine that committed it.
131    Link,
132    /// A path the distribution names as build output, a cache or a worktree container.
133    Artifact,
134    /// Not text: a NUL byte, content that is not UTF-8, or an extension on the binary list.
135    Binary,
136    /// Marked `merge=derived` in `.gitattributes`: a projection of a file that is here.
137    Derived,
138    /// Matched by the profile's `exclude`.
139    Excluded,
140}
141
142impl DropReason {
143    /// The word a report and the manifest use, the same as its serialised form.
144    ///
145    /// ```
146    /// use majordomus_cli::pack::DropReason;
147    /// assert_eq!(DropReason::Link.as_str(), "link");
148    /// assert_eq!(DropReason::Excluded.as_str(), "excluded");
149    /// ```
150    pub fn as_str(self) -> &'static str {
151        match self {
152            DropReason::Worktree => "worktree",
153            DropReason::Link => "link",
154            DropReason::Artifact => "artifact",
155            DropReason::Binary => "binary",
156            DropReason::Derived => "derived",
157            DropReason::Excluded => "excluded",
158        }
159    }
160
161    /// Every reason, in the order of their words, which is the order a report lists them in.
162    pub(crate) const ALL: [DropReason; 6] = [
163        DropReason::Artifact,
164        DropReason::Binary,
165        DropReason::Derived,
166        DropReason::Excluded,
167        DropReason::Link,
168        DropReason::Worktree,
169    ];
170
171    /// What the reason means to a reader of the pack's index.
172    pub(crate) fn meaning(self) -> &'static str {
173        match self {
174            DropReason::Derived => {
175                "generated projections of files that are here (`merge=derived` in `.gitattributes`)"
176            }
177            DropReason::Binary => {
178                "not text: a binary extension, a NUL byte or content that is not UTF-8"
179            }
180            DropReason::Artifact => {
181                "build output, caches and worktree containers committed by accident"
182            }
183            DropReason::Worktree => {
184                "gitlinks: nested repositories or worktrees whose content is not in this index"
185            }
186            DropReason::Link => "symbolic links, whose content is a path on the committing machine",
187            DropReason::Excluded => "matched by the profile's exclude list",
188        }
189    }
190
191    /// Whether an `include` may re-admit a file dropped for this reason.
192    fn reincludable(self) -> bool {
193        matches!(self, DropReason::Derived | DropReason::Excluded)
194    }
195}
196
197/// The shard limits of a profile: the reader's own limits on what it can index, the file
198/// count of a project and the tokens one file of it may hold.
199///
200/// ```
201/// use majordomus_cli::pack::{Profiles, ShardLimits};
202/// let p = Profiles::parse(
203///     "default: a\nprofiles:\n  - id: a\n    shards:\n      max_count: 20\n      max_tokens: 900\n",
204/// )
205/// .unwrap();
206/// let want = ShardLimits { max_count: 20, max_tokens: 900 };
207/// assert_eq!(p.profile(None).unwrap().shards, Some(want));
208/// ```
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
210pub struct ShardLimits {
211    /// The most files the pack may hold, the index included.
212    pub max_count: usize,
213    /// The most tokens one file of the pack may hold, counted with `o200k_base`.
214    pub max_tokens: u64,
215}
216
217/// One profile of `share/archive.yaml`, as far as the pack reads it: what it drops, what it
218/// re-admits, what the index says first and the limits it is cut into.
219///
220/// ```
221/// use majordomus_cli::pack::{PackProfile, Profiles};
222/// let p = Profiles::parse("default: a\nprofiles:\n  - id: a\n    derived: drop\n").unwrap();
223/// let a: &PackProfile = p.profile(Some("a")).unwrap();
224/// assert_eq!(a.derived, "drop");
225/// assert!(a.shards.is_none(), "a profile without limits cannot be packed");
226/// ```
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
228pub struct PackProfile {
229    /// The profile's id.
230    pub id: String,
231    /// One line: what the snapshot is for.
232    pub title: String,
233    /// `drop` leaves out what `.gitattributes` marks `merge=derived`.
234    pub derived: String,
235    /// `drop` leaves out what is not text.
236    pub binary: String,
237    /// `drop` leaves out gitlinks, links and the distribution's artifact paths.
238    pub artifacts: String,
239    /// Globs left out.
240    pub exclude: Vec<String>,
241    /// Globs re-admitted after `exclude` and `derived`.
242    pub include: Vec<String>,
243    /// The paragraphs the index opens with.
244    pub orientation: Vec<String>,
245    /// The shard limits; a profile without them cannot be packed.
246    pub shards: Option<ShardLimits>,
247}
248
249/// What the distribution declares once for every profile: the default, the binary
250/// extensions, the artifact paths, and the profiles themselves.
251///
252/// ```
253/// use majordomus_cli::pack::Profiles;
254/// let p = Profiles::parse(
255///     "default: a\nbinary_extensions: [\"PNG\"]\nprofiles:\n  - id: a\n  - id: b\n",
256/// )
257/// .unwrap();
258/// assert_eq!(p.default, "a");
259/// assert!(p.binary_extensions.contains("png"), "extensions are compared in lower case");
260/// assert_eq!(p.profiles.len(), 2);
261/// ```
262#[derive(Debug, Clone, Default, PartialEq, Eq)]
263pub struct Profiles {
264    /// The profile used when none is named.
265    pub default: String,
266    /// Extensions, without the dot, that are binary whatever their content.
267    pub binary_extensions: BTreeSet<String>,
268    /// Globs of build output, caches and worktree containers.
269    pub artifact_paths: Vec<String>,
270    /// Every profile, in file order.
271    pub profiles: Vec<PackProfile>,
272}
273
274#[derive(Deserialize)]
275struct RawLimits {
276    max_count: usize,
277    max_tokens: u64,
278}
279
280#[derive(Deserialize)]
281struct RawProfile {
282    id: String,
283    #[serde(default)]
284    title: String,
285    #[serde(default)]
286    derived: String,
287    #[serde(default)]
288    binary: String,
289    #[serde(default)]
290    artifacts: String,
291    #[serde(default)]
292    exclude: Vec<String>,
293    #[serde(default)]
294    include: Vec<String>,
295    #[serde(default)]
296    orientation: Vec<String>,
297    #[serde(default)]
298    shards: Option<RawLimits>,
299}
300
301#[derive(Deserialize)]
302struct RawOverlay {
303    #[serde(default)]
304    profiles: Vec<RawProfile>,
305}
306
307#[derive(Deserialize)]
308struct RawProfiles {
309    default: String,
310    #[serde(default)]
311    binary_extensions: Vec<String>,
312    #[serde(default)]
313    artifact_paths: Vec<String>,
314    profiles: Vec<RawProfile>,
315}
316
317impl From<RawProfile> for PackProfile {
318    fn from(p: RawProfile) -> Self {
319        PackProfile {
320            id: p.id,
321            title: p.title,
322            derived: p.derived,
323            binary: p.binary,
324            artifacts: p.artifacts,
325            exclude: p.exclude,
326            include: p.include,
327            orientation: p.orientation,
328            shards: p.shards.map(|s| ShardLimits {
329                max_count: s.max_count,
330                max_tokens: s.max_tokens,
331            }),
332        }
333    }
334}
335
336impl Profiles {
337    /// Read the profiles from the share directory, then the repository's overlay
338    /// (`.ai/repo/archive.yaml`), whose profiles replace the shipped ones of the same id.
339    ///
340    /// ```
341    /// use majordomus_cli::pack::Profiles;
342    /// let dir = tempfile::tempdir().unwrap();
343    /// let (share, root) = (dir.path().join("share"), dir.path().join("repo"));
344    /// std::fs::create_dir_all(&share).unwrap();
345    /// std::fs::create_dir_all(root.join(".ai/repo")).unwrap();
346    /// std::fs::write(share.join("archive.yaml"), "default: a\nprofiles:\n  - id: a\n").unwrap();
347    /// std::fs::write(
348    ///     root.join(".ai/repo/archive.yaml"),
349    ///     "profiles:\n  - id: a\n    title: ours\n",
350    /// )
351    /// .unwrap();
352    /// let p = Profiles::load(&share, &root).unwrap();
353    /// assert_eq!(p.profile(None).unwrap().title, "ours");
354    /// assert!(Profiles::load(&root, &root).is_err(), "no archive.yaml there");
355    /// ```
356    pub fn load(share: &Path, root: &Path) -> Result<Profiles, String> {
357        let path = share.join(PROFILES);
358        let text = std::fs::read_to_string(&path)
359            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
360        let mut profiles =
361            Profiles::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
362        let overlay = root.join(OVERLAY);
363        if let Ok(text) = std::fs::read_to_string(&overlay) {
364            let raw: RawOverlay =
365                crate::metadata::yaml::parse_into(&text).map_err(|e| format!("{OVERLAY}: {e}"))?;
366            profiles.overlay(raw.profiles.into_iter().map(PackProfile::from).collect());
367        }
368        Ok(profiles)
369    }
370
371    /// Put a repository's profiles over the shipped ones: the same id replaces, whole.
372    pub(crate) fn overlay(&mut self, local: Vec<PackProfile>) {
373        for p in local.into_iter().rev() {
374            self.profiles.retain(|q| q.id != p.id);
375            self.profiles.insert(0, p);
376        }
377    }
378
379    /// Parse the text of a profiles file, without the overlay: the `default`, the two lists
380    /// and the profiles, with extensions lowered so that `PNG` and `png` are one.
381    ///
382    /// ```
383    /// use majordomus_cli::pack::Profiles;
384    /// let p = Profiles::parse("default: a\nprofiles:\n  - id: a\n    exclude: [\"x/**\"]\n").unwrap();
385    /// assert_eq!(p.profile(None).unwrap().id, "a");
386    /// assert!(p.profile(Some("b")).is_err());
387    /// ```
388    pub fn parse(text: &str) -> Result<Profiles, String> {
389        let raw: RawProfiles = crate::metadata::yaml::parse_into(text)?;
390        let profiles = raw.profiles.into_iter().map(PackProfile::from).collect();
391        Ok(Profiles {
392            default: raw.default,
393            binary_extensions: raw
394                .binary_extensions
395                .into_iter()
396                .map(|e| e.to_ascii_lowercase())
397                .collect(),
398            artifact_paths: raw.artifact_paths,
399            profiles,
400        })
401    }
402
403    /// The named profile, or the default one when no name is given; an unknown name is
404    /// an error that lists the profiles there are.
405    ///
406    /// ```
407    /// use majordomus_cli::pack::Profiles;
408    /// let p = Profiles::parse("default: a\nprofiles:\n  - id: a\n  - id: b\n").unwrap();
409    /// assert_eq!(p.profile(Some("b")).unwrap().id, "b");
410    /// let e = p.profile(Some("c")).unwrap_err();
411    /// assert!(e.contains("a, b"), "{e}");
412    /// ```
413    pub fn profile(&self, id: Option<&str>) -> Result<&PackProfile, String> {
414        let id = id.unwrap_or(&self.default);
415        self.profiles.iter().find(|p| p.id == id).ok_or_else(|| {
416            let known: Vec<&str> = self.profiles.iter().map(|p| p.id.as_str()).collect();
417            format!("no profile '{id}'; the profiles are {}", known.join(", "))
418        })
419    }
420
421    /// Is `path` one of the distribution's artifact paths: build output, a cache or a
422    /// worktree container that was committed by accident?
423    ///
424    /// ```
425    /// use majordomus_cli::pack::Profiles;
426    /// let p = Profiles::parse(
427    ///     "default: a\nartifact_paths: [\"**/target/**\"]\nprofiles:\n  - id: a\n",
428    /// )
429    /// .unwrap();
430    /// assert!(p.is_artifact("apps/x/target/debug/y.rs"));
431    /// assert!(!p.is_artifact("apps/x/src/target.rs"));
432    /// ```
433    pub fn is_artifact(&self, path: &str) -> bool {
434        self.artifact_paths
435            .iter()
436            .any(|g| Glob::new(g).matches(path))
437    }
438
439    /// Does `path` carry an extension on the binary list? The extension is the file name's
440    /// last, compared in lower case; a dot file such as `.png` has none.
441    ///
442    /// ```
443    /// use majordomus_cli::pack::Profiles;
444    /// let p = Profiles::parse("default: a\nbinary_extensions: [png]\nprofiles:\n  - id: a\n")
445    ///     .unwrap();
446    /// assert!(p.has_binary_extension("site/logo.PNG"));
447    /// assert!(!p.has_binary_extension("dir.png/readme.md"));
448    /// assert!(!p.has_binary_extension(".png"));
449    /// ```
450    pub fn has_binary_extension(&self, path: &str) -> bool {
451        let name = path.rsplit('/').next().unwrap_or(path);
452        match name.rsplit_once('.') {
453            Some((stem, ext)) if !stem.is_empty() => {
454                self.binary_extensions.contains(&ext.to_ascii_lowercase())
455            }
456            _ => false,
457        }
458    }
459}
460
461/// Is this content text the pack can carry? UTF-8 and free of NUL bytes.
462///
463/// ```
464/// use majordomus_cli::pack::is_text;
465/// assert!(is_text(b"fn main() {}\n"));
466/// assert!(!is_text(b"PNG\0\x01"));
467/// assert!(!is_text(&[0xff, 0xfe, 0x41]));
468/// ```
469pub fn is_text(content: &[u8]) -> bool {
470    !content.contains(&0) && std::str::from_utf8(content).is_ok()
471}
472
473/// One entry of the git index.
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub(crate) struct IndexEntry {
476    /// The mode, as git prints it (`100644`, `100755`, `120000`, `160000`).
477    pub mode: String,
478    /// The blob or commit id.
479    pub oid: String,
480    /// The path, relative to the repository root.
481    pub path: String,
482}
483
484/// A file the pack carries, with its content.
485#[derive(Debug, Clone)]
486pub(crate) struct Selected {
487    /// The index entry.
488    pub entry: IndexEntry,
489    /// The content of the index's blob.
490    pub text: String,
491    /// The tokens of the block that carries it in a shard.
492    pub tokens: u64,
493}
494
495/// One planned shard: a Markdown file of the pack, the paths it carries from first to last,
496/// and what it weighs in bytes and in tokens.
497///
498/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
499/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
500///
501/// ```
502/// use majordomus_cli::pack::{plan, ShardPlan};
503/// # use std::process::Command;
504/// # let dir = tempfile::tempdir().unwrap();
505/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
506/// # std::fs::create_dir_all(&root).unwrap();
507/// # std::fs::create_dir_all(&share).unwrap();
508/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n  - id: bare\n    binary: drop\n").unwrap();
509/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
510/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
511/// #     .status().unwrap().success());
512/// # git(&["init", "-q"]);
513/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
514/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
515/// # git(&["add", "-A"]);
516/// # git(&["commit", "-q", "-m", "one"]);
517/// let planned = plan(&root, &share, None);
518/// let first: &ShardPlan = &planned.plan.shards[0];
519/// assert_eq!((first.files, first.first.as_str(), first.last.as_str()), (1, "main.rs", "main.rs"));
520/// assert!(first.file.ends_with(".md") && first.tokens > 0);
521/// ```
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
523pub struct ShardPlan {
524    /// The file name inside the pack.
525    pub file: String,
526    /// How many files it carries.
527    pub files: usize,
528    /// The first path it carries.
529    pub first: String,
530    /// The last path it carries.
531    pub last: String,
532    /// The bytes of the content it carries.
533    pub bytes: u64,
534    /// The tokens of the blocks it carries.
535    pub tokens: u64,
536}
537
538/// One dropped file the report names. Derived files are counted, not listed: they are a
539/// function of files that are in the pack.
540///
541/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
542/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
543///
544/// ```
545/// use majordomus_cli::pack::{plan, DropReason, DroppedFile};
546/// # use std::process::Command;
547/// # let dir = tempfile::tempdir().unwrap();
548/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
549/// # std::fs::create_dir_all(&root).unwrap();
550/// # std::fs::create_dir_all(&share).unwrap();
551/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n  - id: bare\n    binary: drop\n").unwrap();
552/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
553/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
554/// #     .status().unwrap().success());
555/// # git(&["init", "-q"]);
556/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
557/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
558/// # git(&["add", "-A"]);
559/// # git(&["commit", "-q", "-m", "one"]);
560/// let dropped = plan(&root, &share, None).plan.dropped_files;
561/// let want = DroppedFile { path: "logo.png".into(), reason: DropReason::Binary };
562/// assert_eq!(dropped, [want]);
563/// ```
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
565pub struct DroppedFile {
566    /// The path.
567    pub path: String,
568    /// Why.
569    pub reason: DropReason,
570}
571
572/// A dropped file is listed by its path; the path is unique in the index, so it is both the
573/// label and the identity.
574impl Ordered for DroppedFile {
575    fn order_key(&self) -> OrderKey<'_> {
576        OrderKey::plain(&self.path, &self.path)
577    }
578}
579
580/// A reason the pack cannot be built as planned, or a written pack is not what its
581/// manifest says: a stable code, the path when there is one, and the remedy.
582///
583/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
584/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
585///
586/// ```
587/// use majordomus_cli::pack::{plan, PackFinding};
588/// # use std::process::Command;
589/// # let dir = tempfile::tempdir().unwrap();
590/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
591/// # std::fs::create_dir_all(&root).unwrap();
592/// # std::fs::create_dir_all(&share).unwrap();
593/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n  - id: bare\n    binary: drop\n").unwrap();
594/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
595/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
596/// #     .status().unwrap().success());
597/// # git(&["init", "-q"]);
598/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
599/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
600/// # git(&["add", "-A"]);
601/// # git(&["commit", "-q", "-m", "one"]);
602/// let findings: Vec<PackFinding> = plan(&root, &share, Some("bare")).plan.findings;
603/// assert_eq!(findings[0].code, "pack.no_limits");
604/// assert!(findings[0].path.is_none() && !findings[0].remedy.is_empty());
605/// ```
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
607pub struct PackFinding {
608    /// A stable code: `pack.file_too_large`, `pack.too_many_shards`, `pack.index_too_large`, `pack.leak`,
609    /// `pack.empty`, `pack.no_limits`, and from verification `pack.tampered`,
610    /// `pack.not_text`, `pack.forbidden_path`, `pack.missing`, `pack.stray`,
611    /// `pack.over_budget`, `pack.manifest`.
612    pub code: String,
613    /// The path the finding is about, when it is about one.
614    pub path: Option<String>,
615    /// What is wrong.
616    pub message: String,
617    /// What settles it.
618    pub remedy: String,
619}
620
621impl PackFinding {
622    fn new(code: &str, path: Option<&str>, message: String, remedy: &str) -> Self {
623        PackFinding {
624            code: code.into(),
625            path: path.map(str::to_string),
626            message,
627            remedy: remedy.into(),
628        }
629    }
630}
631
632/// The plan of one pack: what it carries, what it leaves out and why, how it is cut into
633/// shards, and whether it may be built.
634///
635/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
636/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
637///
638/// ```
639/// use majordomus_cli::pack::{plan, PackPlan};
640/// # use std::process::Command;
641/// # let dir = tempfile::tempdir().unwrap();
642/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
643/// # std::fs::create_dir_all(&root).unwrap();
644/// # std::fs::create_dir_all(&share).unwrap();
645/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n  - id: bare\n    binary: drop\n").unwrap();
646/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
647/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
648/// #     .status().unwrap().success());
649/// # git(&["init", "-q"]);
650/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
651/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
652/// # git(&["add", "-A"]);
653/// # git(&["commit", "-q", "-m", "one"]);
654/// let p: PackPlan = plan(&root, &share, None).plan;
655/// assert!(p.measured && p.passes && p.index_matches_head);
656/// assert_eq!((p.tracked, p.selected), (2, 1));
657/// assert_eq!(p.dropped["binary"], 1);
658/// // an unknown profile is not measured, and not measured is never a pass
659/// let p: PackPlan = plan(&root, &share, Some("nope")).plan;
660/// assert!(!p.measured && !p.passes);
661/// ```
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
663pub struct PackPlan {
664    /// Whether the tree could be read at all. `false` is not a pass.
665    pub measured: bool,
666    /// Why it could not, when it could not.
667    pub reason: Option<String>,
668    /// The profile planned.
669    pub profile: String,
670    /// The commit `HEAD` names; the content is the index's, which equals it when
671    /// `index_matches_head` is true.
672    pub commit: Option<String>,
673    /// Whether the index holds exactly what `HEAD` holds. `false` means staged changes
674    /// travel with the pack under a commit id that does not contain them.
675    pub index_matches_head: bool,
676    /// How many files the index tracks.
677    pub tracked: usize,
678    /// How many the pack carries.
679    pub selected: usize,
680    /// The bytes of the content carried.
681    pub bytes: u64,
682    /// The tokens of the content carried, in `o200k_base`.
683    pub tokens: u64,
684    /// The tokenizer the counts are in.
685    pub tokenizer: String,
686    /// The limits applied.
687    pub limits: Option<ShardLimits>,
688    /// How many files were left out, by reason.
689    pub dropped: BTreeMap<String, usize>,
690    /// Every file left out for a reason other than `derived`.
691    pub dropped_files: Vec<DroppedFile>,
692    /// The shards, in order; the index is not one of them.
693    pub shards: Vec<ShardPlan>,
694    /// Every reason the pack cannot be built. Empty is the passing answer.
695    pub findings: Vec<PackFinding>,
696    /// `true` when the tree was measured and nothing was found.
697    pub passes: bool,
698}
699
700impl PackPlan {
701    fn unmeasured(profile: &str, reason: String) -> PackPlan {
702        PackPlan {
703            measured: false,
704            reason: Some(reason),
705            profile: profile.into(),
706            commit: None,
707            index_matches_head: false,
708            tracked: 0,
709            selected: 0,
710            bytes: 0,
711            tokens: 0,
712            tokenizer: crate::economics::context::TOKENIZER_ENCODING.into(),
713            limits: None,
714            dropped: BTreeMap::new(),
715            dropped_files: Vec::new(),
716            shards: Vec::new(),
717            findings: Vec::new(),
718            passes: false,
719        }
720    }
721}
722
723/// A plan with the content it was made from, which is what [`build`] writes: the verdict, the
724/// profile it was made with, and the selected blobs in shard order.
725///
726/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
727/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
728///
729/// ```
730/// use majordomus_cli::pack::{plan, Planned};
731/// # use std::process::Command;
732/// # let dir = tempfile::tempdir().unwrap();
733/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
734/// # std::fs::create_dir_all(&root).unwrap();
735/// # std::fs::create_dir_all(&share).unwrap();
736/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n  - id: bare\n    binary: drop\n").unwrap();
737/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
738/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
739/// #     .status().unwrap().success());
740/// # git(&["init", "-q"]);
741/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
742/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
743/// # git(&["add", "-A"]);
744/// # git(&["commit", "-q", "-m", "one"]);
745/// let planned: Planned = plan(&root, &share, Some("chat"));
746/// assert_eq!(planned.profile.id, "chat");
747/// assert_eq!(planned.plan.profile, planned.profile.id);
748/// ```
749pub struct Planned {
750    /// The verdict and the shape.
751    pub plan: PackPlan,
752    /// The profile it was planned with.
753    pub profile: PackProfile,
754    /// The profiles it was chosen from, which verify what [`build`] writes; empty when the
755    /// profiles could not be read.
756    pub profiles: Profiles,
757    /// The selected files, in shard order, with their shard's index into `plan.shards`.
758    pub(crate) files: Vec<(usize, Selected)>,
759}
760
761/// Plan a pack of the repository at `root` with a profile of the distribution at `share`:
762/// the index's blobs, selected by the profile, cut into shards and judged. Nothing is
763/// written; a tree or a profile that cannot be read is an unmeasured plan, never a pass.
764///
765/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
766/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
767///
768/// ```
769/// use majordomus_cli::pack::plan;
770/// # use std::process::Command;
771/// # let dir = tempfile::tempdir().unwrap();
772/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
773/// # std::fs::create_dir_all(&root).unwrap();
774/// # std::fs::create_dir_all(&share).unwrap();
775/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n  - id: bare\n    binary: drop\n").unwrap();
776/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
777/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
778/// #     .status().unwrap().success());
779/// # git(&["init", "-q"]);
780/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
781/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
782/// # git(&["add", "-A"]);
783/// # git(&["commit", "-q", "-m", "one"]);
784/// let planned = plan(&root, &share, None);
785/// assert!(planned.plan.passes, "{:?}", planned.plan.findings);
786/// assert_eq!(planned.plan.profile, "chat");
787/// assert_eq!(planned.plan.shards.len(), 1);
788/// ```
789pub fn plan(root: &Path, share: &Path, profile: Option<&str>) -> Planned {
790    let label = profile.unwrap_or("").to_string();
791    let profiles = match Profiles::load(share, root) {
792        Ok(p) => p,
793        Err(e) => return unmeasured(&label, e),
794    };
795    let chosen = match profiles.profile(profile) {
796        Ok(p) => p.clone(),
797        Err(e) => return unmeasured(&label, e),
798    };
799    plan_with(root, &profiles, &chosen).unwrap_or_else(|e| unmeasured(&chosen.id, e))
800}
801
802/// A plan that could not be made, with the reason: never a pass, and never built.
803pub(crate) fn unmeasured(profile: &str, reason: String) -> Planned {
804    Planned {
805        plan: PackPlan::unmeasured(profile, reason),
806        profile: PackProfile {
807            id: profile.into(),
808            title: String::new(),
809            derived: String::new(),
810            binary: String::new(),
811            artifacts: String::new(),
812            exclude: Vec::new(),
813            include: Vec::new(),
814            orientation: Vec::new(),
815            shards: None,
816        },
817        profiles: Profiles::default(),
818        files: Vec::new(),
819    }
820}
821
822/// Plan with profiles already read: the body of [`plan`], for a caller that holds the
823/// profiles and the chosen one.
824pub(crate) fn plan_with(
825    root: &Path,
826    profiles: &Profiles,
827    profile: &PackProfile,
828) -> Result<Planned, String> {
829    let (entries, derived) = index_entries(root, profile.derived == "drop")?;
830    let exclude: Vec<Glob> = profile.exclude.iter().map(|g| Glob::new(g)).collect();
831    let include: Vec<Glob> = profile.include.iter().map(|g| Glob::new(g)).collect();
832    let artifacts = profile.artifacts == "drop";
833    let binary = profile.binary == "drop";
834
835    let mut dropped: BTreeMap<String, usize> = BTreeMap::new();
836    let mut dropped_files = Vec::new();
837    let mut kept = Vec::new();
838    let mut drop = |path: &str, reason: DropReason, dropped: &mut BTreeMap<String, usize>| {
839        *dropped.entry(reason.as_str().to_string()).or_insert(0) += 1;
840        if reason != DropReason::Derived {
841            dropped_files.push(DroppedFile {
842                path: path.to_string(),
843                reason,
844            });
845        }
846    };
847    for e in &entries {
848        let reason = if artifacts && e.mode == "160000" {
849            Some(DropReason::Worktree)
850        } else if artifacts && e.mode == "120000" {
851            Some(DropReason::Link)
852        } else if artifacts && profiles.is_artifact(&e.path) {
853            Some(DropReason::Artifact)
854        } else if binary && profiles.has_binary_extension(&e.path) {
855            Some(DropReason::Binary)
856        } else if derived.contains(&e.path) {
857            Some(DropReason::Derived)
858        } else if exclude.iter().any(|g| g.matches(&e.path)) {
859            Some(DropReason::Excluded)
860        } else {
861            None
862        };
863        let reason =
864            reason.filter(|r| !(r.reincludable() && include.iter().any(|g| g.matches(&e.path))));
865        // a gitlink names a commit, not a blob: there is nothing to read, so one that is
866        // kept (a profile that keeps artifacts) is still left out, and said so
867        match reason {
868            Some(r) => drop(&e.path, r, &mut dropped),
869            None if e.mode == "160000" => drop(&e.path, DropReason::Worktree, &mut dropped),
870            None => kept.push(e.clone()),
871        }
872    }
873
874    let commit = head(root);
875    let blobs = read_blobs(root, &kept)?;
876    let machine = machine_paths(root);
877    let mut findings = Vec::new();
878    let mut selected = Vec::new();
879    for (e, content) in kept.into_iter().zip(blobs) {
880        if binary && !is_text(&content) {
881            drop(&e.path, DropReason::Binary, &mut dropped);
882            continue;
883        }
884        let Ok(text) = String::from_utf8(content) else {
885            // a profile that keeps binaries cannot carry one in a text pack either
886            drop(&e.path, DropReason::Binary, &mut dropped);
887            continue;
888        };
889        if let Some(line) = names_machine(&text, &machine) {
890            findings.push(PackFinding::new(
891                "pack.leak",
892                Some(&e.path),
893                format!("line {line} names this machine: the checkout's path or the account's home directory"),
894                "write what the path means rather than the path, commit, and plan again; a pack never carries it",
895            ));
896        }
897        let tokens = shard::block_tokens(&e, &text);
898        selected.push(Selected {
899            entry: e,
900            text,
901            tokens,
902        });
903    }
904
905    let limits = profile.shards;
906    let mut shards = Vec::new();
907    let mut files = Vec::new();
908    match limits {
909        None => findings.push(PackFinding::new(
910            "pack.no_limits",
911            None,
912            format!("profile '{}' declares no `shards:` limits", profile.id),
913            "add `shards: {max_count, max_tokens}` to the profile in share/archive.yaml, or pack with a profile that has them",
914        )),
915        Some(limits) => {
916            let budget = limits.max_tokens * FILL_PERCENT / 100;
917            for s in selected {
918                if s.tokens > budget {
919                    findings.push(PackFinding::new(
920                        "pack.file_too_large",
921                        Some(&s.entry.path),
922                        format!(
923                            "{} tokens, more than one shard's budget of {budget}",
924                            s.tokens
925                        ),
926                        "exclude the file in the profile, or raise `shards.max_tokens` if the reader accepts it",
927                    ));
928                    continue;
929                }
930                let open = shards
931                    .last()
932                    .is_some_and(|sh: &ShardPlan| sh.tokens + s.tokens <= budget);
933                if !open {
934                    shards.push(ShardPlan {
935                        file: String::new(),
936                        files: 0,
937                        first: s.entry.path.clone(),
938                        last: String::new(),
939                        bytes: 0,
940                        tokens: shard::header_tokens(&s.entry.path, commit.as_deref(), &profile.id),
941                    });
942                }
943                let i = shards.len() - 1;
944                let sh = &mut shards[i];
945                sh.files += 1;
946                sh.last = s.entry.path.clone();
947                sh.bytes += s.text.len() as u64;
948                sh.tokens += s.tokens;
949                files.push((i, s));
950            }
951            for (i, sh) in shards.iter_mut().enumerate() {
952                sh.file = shard::shard_name(i + 1, &sh.first);
953            }
954            if shards.len() + 1 > limits.max_count {
955                findings.push(PackFinding::new(
956                    "pack.too_many_shards",
957                    None,
958                    format!(
959                        "{} shard(s) and the index, more than the {} file(s) the profile allows",
960                        shards.len(),
961                        limits.max_count
962                    ),
963                    "exclude more of the tree in the profile, or raise `shards.max_tokens` so fewer shards hold it",
964                ));
965            }
966        }
967    }
968    if files.is_empty() && limits.is_some() {
969        findings.push(PackFinding::new(
970            "pack.empty",
971            None,
972            "the profile selects no file".into(),
973            "a pack of nothing is not a pack; check the profile's exclude list",
974        ));
975    }
976
977    let bytes = files.iter().map(|(_, s)| s.text.len() as u64).sum();
978    let tokens = files.iter().map(|(_, s)| s.tokens).sum();
979    canonical(&mut dropped_files);
980    let mut plan = PackPlan {
981        measured: true,
982        reason: None,
983        profile: profile.id.clone(),
984        commit,
985        index_matches_head: index_matches_head(root),
986        tracked: entries.len(),
987        selected: files.len(),
988        bytes,
989        tokens,
990        tokenizer: crate::economics::context::TOKENIZER_ENCODING.into(),
991        limits,
992        dropped,
993        dropped_files,
994        shards,
995        passes: findings.is_empty(),
996        findings,
997    };
998    // the index is a file of the pack like any shard, and is held to the same budget
999    if let Some(l) = limits.filter(|_| plan.passes) {
1000        let tokens = shard::index_tokens(profile, &plan);
1001        if tokens > l.max_tokens * FILL_PERCENT / 100 {
1002            plan.findings.push(PackFinding::new(
1003                "pack.index_too_large",
1004                Some(INDEX),
1005                format!("the index would hold {tokens} tokens, more than one file's budget"),
1006                "exclude whole directories rather than single files, so fewer files are listed as left out",
1007            ));
1008            plan.passes = false;
1009        }
1010    }
1011    Ok(Planned {
1012        plan,
1013        profile: profile.clone(),
1014        profiles: profiles.clone(),
1015        files,
1016    })
1017}
1018
1019/// Is `home` the home of a hosted CI runner, as the repository's one definition of that
1020/// shape says (`runner-path` in `src/quality/leak-patterns.tsv`)? Such a home names no
1021/// person's machine, and the definition itself, which the pack carries, spells it out.
1022fn is_runner_home(home: &Path) -> bool {
1023    let text = format!("{}/", home.display());
1024    crate::quality::leaks::Leaks::shipped()
1025        .map(|l| {
1026            !l.scan(
1027                text.as_bytes(),
1028                &[crate::quality::leaks::LeakClass::RunnerPath],
1029            )
1030            .is_empty()
1031        })
1032        .unwrap_or(false)
1033}
1034
1035/// The first line of `text` naming one of `machine`, 1-based.
1036pub(crate) fn names_machine(text: &str, machine: &[String]) -> Option<usize> {
1037    if !machine.iter().any(|m| text.contains(m.as_str())) {
1038        return None;
1039    }
1040    text.lines()
1041        .position(|l| machine.iter().any(|m| l.contains(m.as_str())))
1042        .map(|n| n + 1)
1043}
1044
1045/// Run git in `root` with `input` on its standard input and answer what it printed. A git
1046/// that cannot be started (a root that does not exist) and a git that fails are both an
1047/// error naming the subcommand, so every read below has one way to fail.
1048fn git_output(root: &Path, args: &[&str], input: Vec<u8>) -> Result<Vec<u8>, String> {
1049    // absolute, so that `-C` and the working directory name the same place; a root with no
1050    // absolute form becomes the empty path, which no process can be started in
1051    let root = std::path::absolute(root).unwrap_or_default();
1052    let run = crate::git::read_only(&root)
1053        .current_dir(&root)
1054        .args(args)
1055        .stdin(Stdio::piped())
1056        .stdout(Stdio::piped())
1057        .stderr(Stdio::piped())
1058        .spawn()
1059        .and_then(|mut child| {
1060            let writer = child
1061                .stdin
1062                .take()
1063                .map(|mut stdin| std::thread::spawn(move || stdin.write_all(&input)));
1064            let out = child.wait_with_output();
1065            let _ = writer.map(std::thread::JoinHandle::join);
1066            out
1067        });
1068    match run {
1069        Ok(out) if out.status.success() => Ok(out.stdout),
1070        Ok(out) => Err(format!(
1071            "git {}: {}",
1072            args[0],
1073            String::from_utf8_lossy(&out.stderr).trim()
1074        )),
1075        Err(e) => Err(format!("cannot run git {}: {e}", args[0])),
1076    }
1077}
1078
1079/// Every entry of the index in git's order, and, when `derived` is asked, the paths
1080/// `.gitattributes` marks `merge=derived`, asked of git itself.
1081fn index_entries(
1082    root: &Path,
1083    derived: bool,
1084) -> Result<(Vec<IndexEntry>, BTreeSet<String>), String> {
1085    git_output(root, &["ls-files", "-s", "-z"], Vec::new())
1086        .and_then(|out| parse_index(&out))
1087        .and_then(|entries| {
1088            if derived {
1089                derived_paths(root, &entries).map(|d| (entries, d))
1090            } else {
1091                Ok((entries, BTreeSet::new()))
1092            }
1093        })
1094}
1095
1096/// The records of `git ls-files -s -z`: `<mode> <oid> <stage>\t<path>`. A record that is not
1097/// a stage-0 entry is a conflict nobody resolved, and a pack of it would carry one side.
1098fn parse_index(out: &[u8]) -> Result<Vec<IndexEntry>, String> {
1099    let mut entries = Vec::new();
1100    for record in out.split(|b| *b == 0).filter(|r| !r.is_empty()) {
1101        let record = String::from_utf8_lossy(record);
1102        let (meta, path) = record.split_once('\t').unwrap_or((&record, ""));
1103        let mut parts = meta.splitn(3, ' ');
1104        let (mode, oid, stage) = (
1105            parts.next().unwrap_or_default(),
1106            parts.next().unwrap_or_default(),
1107            parts.next().unwrap_or_default(),
1108        );
1109        if stage != "0" {
1110            return Err(format!(
1111                "{path} is unmerged in the index; resolve the conflict before packing"
1112            ));
1113        }
1114        entries.push(IndexEntry {
1115            mode: mode.into(),
1116            oid: oid.into(),
1117            path: path.into(),
1118        });
1119    }
1120    Ok(entries)
1121}
1122
1123/// The paths `.gitattributes` marks `merge=derived`, asked of git itself.
1124fn derived_paths(root: &Path, entries: &[IndexEntry]) -> Result<BTreeSet<String>, String> {
1125    let paths: Vec<u8> = entries
1126        .iter()
1127        .flat_map(|e| e.path.bytes().chain(std::iter::once(0)))
1128        .collect();
1129    git_output(root, &["check-attr", "-z", "--stdin", "merge"], paths).map(|out| {
1130        // "<path>\0merge\0<value>\0" per path
1131        let fields: Vec<&[u8]> = out.split(|b| *b == 0).collect();
1132        fields
1133            .chunks(3)
1134            .filter(|c| c.len() == 3 && c[2] == b"derived")
1135            .map(|c| String::from_utf8_lossy(c[0]).into_owned())
1136            .collect()
1137    })
1138}
1139
1140/// The content of every entry's blob, in order, through one `git cat-file --batch`.
1141fn read_blobs(root: &Path, entries: &[IndexEntry]) -> Result<Vec<Vec<u8>>, String> {
1142    let request: Vec<u8> = entries
1143        .iter()
1144        .flat_map(|e| e.oid.bytes().chain(std::iter::once(b'\n')))
1145        .collect();
1146    git_output(root, &["cat-file", "--batch"], request).and_then(|out| split_blobs(&out, entries))
1147}
1148
1149/// The objects of a `git cat-file --batch` answer, one per entry: `<oid> blob <size>\n`,
1150/// the content and a newline. Anything else — a missing object, an answer cut short — is
1151/// named by the entry it was the answer for.
1152fn split_blobs(out: &[u8], entries: &[IndexEntry]) -> Result<Vec<Vec<u8>>, String> {
1153    let mut blobs = Vec::with_capacity(entries.len());
1154    let mut at = 0;
1155    for e in entries {
1156        let rest = out.get(at..).unwrap_or_default();
1157        let nl = rest.iter().position(|b| *b == b'\n').unwrap_or(rest.len());
1158        let header = String::from_utf8_lossy(&rest[..nl]).into_owned();
1159        let start = at + nl + 1;
1160        let end = header
1161            .strip_prefix(&format!("{} blob ", e.oid))
1162            .and_then(|size| size.parse::<usize>().ok())
1163            .map(|size| start + size)
1164            .filter(|end| *end <= out.len());
1165        let Some(end) = end else {
1166            return Err(format!("{}: git cat-file answered '{header}'", e.path));
1167        };
1168        blobs.push(out[start..end].to_vec());
1169        at = end + 1;
1170    }
1171    Ok(blobs)
1172}
1173
1174fn head(root: &Path) -> Option<String> {
1175    git_output(root, &["rev-parse", "--verify", "-q", "HEAD"], Vec::new())
1176        .ok()
1177        .map(|out| String::from_utf8_lossy(&out).trim().to_string())
1178}
1179
1180fn index_matches_head(root: &Path) -> bool {
1181    git_output(
1182        root,
1183        &["diff", "--cached", "--quiet", "HEAD", "--"],
1184        Vec::new(),
1185    )
1186    .is_ok()
1187}
1188
1189/// Where a pack of `plan` is written when no directory is named: under the repository's
1190/// ignored `tmp/`, named for the repository, the profile and the commit. The repository's
1191/// name is its primary checkout's, which a linked worktree shares: a worktree's directory
1192/// is named for its branch.
1193pub(crate) fn default_out(root: &Path, plan: &PackPlan) -> PathBuf {
1194    let common = git_output(
1195        root,
1196        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
1197        Vec::new(),
1198    )
1199    .ok()
1200    .map(|o| PathBuf::from(String::from_utf8_lossy(&o).trim()));
1201    let named = common
1202        .as_deref()
1203        .filter(|c| c.file_name().is_some_and(|n| n == ".git"))
1204        .and_then(Path::parent)
1205        .unwrap_or(root);
1206    let repo = named
1207        .file_name()
1208        .unwrap_or(std::ffi::OsStr::new("repository"))
1209        .to_string_lossy();
1210    let short: String = plan
1211        .commit
1212        .as_deref()
1213        .unwrap_or("uncommitted")
1214        .chars()
1215        .take(12)
1216        .collect();
1217    root.join("tmp")
1218        .join("packs")
1219        .join(format!("{repo}-{}-{short}", plan.profile))
1220}
1221
1222/// Write a planned pack into `out`, which must be absent, empty, or a pack this command
1223/// wrote (it holds a manifest); then verify what was written.
1224///
1225/// The examples below plan a repository holding `main.rs` and a `logo.png` whose content is
1226/// binary, with a distribution whose `chat` profile has limits and whose `bare` one has none.
1227///
1228/// ```
1229/// use majordomus_cli::pack::{build, plan, INDEX, MANIFEST};
1230/// # use std::process::Command;
1231/// # let dir = tempfile::tempdir().unwrap();
1232/// # let (root, share) = (dir.path().join("repo"), dir.path().join("share"));
1233/// # std::fs::create_dir_all(&root).unwrap();
1234/// # std::fs::create_dir_all(&share).unwrap();
1235/// # std::fs::write(share.join("archive.yaml"), "default: chat\nprofiles:\n  - id: chat\n    binary: drop\n    artifacts: drop\n    shards:\n      max_count: 5\n      max_tokens: 4000\n  - id: bare\n    binary: drop\n").unwrap();
1236/// # let git = |a: &[&str]| assert!(Command::new("git").arg("-C").arg(&root)
1237/// #     .args(["-c", "user.email=t@example.com", "-c", "user.name=t"]).args(a)
1238/// #     .status().unwrap().success());
1239/// # git(&["init", "-q"]);
1240/// # std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
1241/// # std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
1242/// # git(&["add", "-A"]);
1243/// # git(&["commit", "-q", "-m", "one"]);
1244/// let out = dir.path().join("pack");
1245/// let verdict = build(&plan(&root, &share, None), &root, &out).unwrap();
1246/// assert!(verdict.passes, "{:?}", verdict.findings);
1247/// assert!(out.join(INDEX).is_file() && out.join(MANIFEST).is_file());
1248/// // a plan with a finding is never written
1249/// let refused = build(&plan(&root, &share, Some("bare")), &root, &out);
1250/// assert!(refused.is_err());
1251/// ```
1252pub fn build(planned: &Planned, root: &Path, out: &Path) -> Result<PackVerdict, String> {
1253    if !planned.plan.passes {
1254        return Err("the plan has findings; a pack with a finding is not built".into());
1255    }
1256    if out.exists() {
1257        let names: Vec<String> = std::fs::read_dir(out)
1258            .map_err(|e| format!("cannot read {}: {e}", out.display()))?
1259            .flatten()
1260            .map(|e| e.file_name().to_string_lossy().into_owned())
1261            .collect();
1262        // every name is judged before anything is removed, so a refusal removes nothing
1263        if !names.is_empty() && !out.join(MANIFEST).is_file() {
1264            return Err(format!(
1265                "{} is not empty and holds no {MANIFEST}: it is not a pack, and nothing in it is removed",
1266                out.display()
1267            ));
1268        }
1269        if let Some(name) = names.iter().find(|n| *n != MANIFEST && !n.ends_with(".md")) {
1270            return Err(format!(
1271                "{} holds {name}, which no pack writes; remove it by hand",
1272                out.display()
1273            ));
1274        }
1275        for name in &names {
1276            std::fs::remove_file(out.join(name))
1277                .map_err(|e| format!("cannot replace {name}: {e}"))?;
1278        }
1279    }
1280    std::fs::create_dir_all(out).map_err(|e| format!("cannot create {}: {e}", out.display()))?;
1281    shard::write(planned, out)?;
1282    Ok(verify(out, &planned.profiles, root))
1283}
1284
1285#[cfg(test)]
1286mod tests;