Skip to main content

boxology_cli_core/
base.rs

1//! Git-backed base-revision resolution and schema ingestion for `boxology check`.
2#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4
5use crate::{ExecuteError, GenerationPlan, PackageSchemas, execute::read_optional_file};
6use boxology_manifest::RelativePath;
7use boxology_workspace::{
8    CargoManifestChange, DiffOwnership, FileEntry, Findings, Package, WorkspaceInputs,
9};
10use std::{
11    collections::BTreeMap,
12    fmt, fs, io,
13    path::Path,
14    process::{Command, Output},
15};
16
17type Rule = (&'static str, &'static str, &'static str);
18const BASE_GIT_SOURCE: &str = "specs/s5-manifest-and-validation.md D6";
19const BASE_REVISION_TEXT: &str = "the explicit base revision must resolve to a Git commit";
20const BASE_SCHEMA_TEXT: &str = "a base-revision schema object must be readable as a Git blob";
21const REVISION: Rule = ("BXW0091", BASE_REVISION_TEXT, BASE_GIT_SOURCE);
22const BASE_SCHEMA: Rule = ("BXW0092", BASE_SCHEMA_TEXT, BASE_GIT_SOURCE);
23const BASE_DISCOVERY_SOURCE: &str =
24    "boxology-details/02-packages.md discovery walk; specs/s5-manifest-and-validation.md D6";
25const BASE_LISTING_TEXT: &str =
26    "the base revision's Git listings must parse as expected NUL-delimited output";
27const BASE_BLOB_TEXT: &str = "a base-revision workspace object must be readable as a Git blob";
28const BASE_DECLARATIONS_TEXT: &str =
29    "the base revision's package declarations must form a discoverable workspace";
30const CANDIDATE_CARGO_TEXT: &str =
31    "a changed candidate Cargo manifest must be a readable regular file when present";
32const BASE_LISTING: Rule = ("BXW0103", BASE_LISTING_TEXT, BASE_GIT_SOURCE);
33const BASE_BLOB: Rule = ("BXW0104", BASE_BLOB_TEXT, BASE_GIT_SOURCE);
34const BASE_DECLARATIONS: Rule = ("BXW0105", BASE_DECLARATIONS_TEXT, BASE_DISCOVERY_SOURCE);
35const CANDIDATE_CARGO: Rule = ("BXW0106", CANDIDATE_CARGO_TEXT, BASE_GIT_SOURCE);
36
37/// The Git executable could not be started for a base check.
38#[derive(Debug, Eq, PartialEq)]
39pub struct GitToolError;
40
41impl fmt::Display for GitToolError {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        formatter.write_str("git could not be executed")
44    }
45}
46
47impl std::error::Error for GitToolError {}
48
49/// A stable failure to resolve the explicit base or read one of its schema objects.
50#[derive(Debug, Eq, PartialEq)]
51pub struct BaseError {
52    code: &'static str,
53    location: String,
54    detail: &'static str,
55}
56
57impl BaseError {
58    /// Returns the stable `BXW####` code.
59    pub fn code(&self) -> &'static str {
60        self.code
61    }
62    /// Returns the stable non-secret location (`.git` or a plan-authoritative schema path).
63    pub fn location(&self) -> &str {
64        &self.location
65    }
66    /// Returns the stable rule detail.
67    pub fn detail(&self) -> &'static str {
68        self.detail
69    }
70
71    fn revision() -> Self {
72        Self {
73            code: REVISION.0,
74            location: ".git".to_owned(),
75            detail: REVISION.1,
76        }
77    }
78    fn schema(plan: &GenerationPlan) -> Self {
79        Self {
80            code: BASE_SCHEMA.0,
81            location: plan.schema_path().as_str().to_owned(),
82            detail: BASE_SCHEMA.1,
83        }
84    }
85    fn at(rule: Rule, location: impl Into<String>) -> Self {
86        Self {
87            code: rule.0,
88            location: location.into(),
89            detail: rule.1,
90        }
91    }
92    fn listing() -> Self {
93        Self::at(BASE_LISTING, ".git")
94    }
95    fn blob(path: &RelativePath) -> Self {
96        Self::at(BASE_BLOB, path.as_str())
97    }
98    fn declarations() -> Self {
99        Self::at(BASE_DECLARATIONS, ".git")
100    }
101    fn cargo(path: &RelativePath) -> Self {
102        Self::at(CANDIDATE_CARGO, path.as_str())
103    }
104}
105
106impl fmt::Display for BaseError {
107    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108        write!(
109            formatter,
110            "{} {}: {}",
111            self.code, self.location, self.detail
112        )
113    }
114}
115
116impl std::error::Error for BaseError {}
117
118/// Failure while assembling current and base-revision package schemas.
119#[derive(Debug)]
120pub enum BaseSchemasError {
121    /// The Git executable could not be started.
122    Tool(GitToolError),
123    /// Git could not resolve or read the requested base.
124    Git(BaseError),
125    /// The current checked-in schema did not satisfy the existing filesystem guard.
126    Submitted(ExecuteError),
127}
128
129/// Failure while assembling base-revision ownership inputs.
130#[derive(Debug)]
131pub enum BaseInputsError {
132    /// The Git executable could not be started.
133    Tool(GitToolError),
134    /// A coded Git listing, blob, or candidate-path failure.
135    Data(BaseError),
136    /// Base package discovery produced findings under a BXW0105 header.
137    Declarations {
138        /// The BXW0105 header diagnostic.
139        header: BaseError,
140        /// Deterministic base discovery findings.
141        findings: Findings,
142    },
143}
144impl fmt::Display for BaseInputsError {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            Self::Tool(e) => e.fmt(f),
148            Self::Data(e) => e.fmt(f),
149            Self::Declarations { header, findings } => write!(f, "{header}\n{findings}"),
150        }
151    }
152}
153impl std::error::Error for BaseInputsError {}
154
155/// Outcome of resolving the no-flag default base against the fixed v0 branch `main`.
156#[derive(Debug, Eq, PartialEq)]
157pub enum DefaultBase {
158    /// Merge base of `HEAD` and `main`, as a single trimmed Git stdout line.
159    Commit(String),
160    /// `root` is not inside a Git repository.
161    NoRepository,
162    /// No merge base exists between `HEAD` and `main`.
163    NoMergeBase,
164}
165
166/// One validated base commit, resolved once per check.
167#[derive(Debug, Eq, PartialEq)]
168pub struct ResolvedBase(String);
169impl ResolvedBase {
170    /// Returns the validated object id.
171    pub fn as_str(&self) -> &str {
172        &self.0
173    }
174    /// Accepts exactly one trimmed 40- or 64-character ASCII-hex object id.
175    ///
176    /// # Errors
177    /// Returns `BXW0091` when `oid` is not a trimmed full hex object id.
178    pub fn from_oid(oid: String) -> Result<Self, BaseError> {
179        let oid = oid.trim();
180        valid_oid(oid)
181            .then(|| Self(oid.to_owned()))
182            .ok_or_else(BaseError::revision)
183    }
184}
185
186/// Resolves the no-flag default base: merge base of `HEAD` with the fixed v0 branch `main`.
187///
188/// # Errors
189/// Returns [`GitToolError`] when the Git executable cannot be started.
190pub fn resolve_default_base(root: &Path) -> Result<DefaultBase, GitToolError> {
191    let git_dir = git(root, &["rev-parse", "--git-dir"])
192        .output()
193        .map_err(|_| GitToolError)?;
194    if !git_dir.status.success() {
195        return Ok(DefaultBase::NoRepository);
196    }
197    let merge = git(root, &["merge-base", "HEAD", "main"])
198        .output()
199        .map_err(|_| GitToolError)?;
200    if !merge.status.success() {
201        return Ok(DefaultBase::NoMergeBase);
202    }
203    let oid = std::str::from_utf8(&merge.stdout)
204        .map(str::trim)
205        .unwrap_or("")
206        .to_owned();
207    Ok(DefaultBase::Commit(oid))
208}
209
210/// Resolves an explicit base revision to one validated commit object id.
211///
212/// # Errors
213/// Returns `BXW0091` when `revision` is not a commit, or [`GitToolError`] when Git cannot start.
214pub fn resolve_base(root: &Path, revision: &str) -> Result<ResolvedBase, BaseSchemasError> {
215    Ok(ResolvedBase(resolve_commit(root, revision)?))
216}
217
218/// Assembles schema pairs for every current generation plan against one resolved base.
219///
220/// The current plan is the sole authority for schema paths. A path absent at the resolved base is
221/// represented as `None`; an object present there but not readable as a blob is `BXW0092`.
222///
223/// # Errors
224/// Returns `BXW0092` for an unreadable base object, or the existing `BXW0076` current-schema
225/// filesystem error.
226pub fn base_package_schemas(
227    root: &Path,
228    base: &ResolvedBase,
229    plans: &[GenerationPlan],
230) -> Result<Vec<PackageSchemas>, BaseSchemasError> {
231    let oid = base.as_str();
232    plans
233        .iter()
234        .map(|plan| {
235            let submitted = read_optional_file(root, plan.schema_path())
236                .map_err(BaseSchemasError::Submitted)?
237                .ok_or_else(|| {
238                    BaseSchemasError::Submitted(crate::execute::missing_schema(root, plan))
239                })?;
240            let base = read_schema(root, oid, plan)?;
241            Ok(PackageSchemas::new(
242                plan.package_id().clone(),
243                base,
244                submitted,
245            ))
246        })
247        .collect()
248}
249
250fn resolve_commit(root: &Path, revision: &str) -> Result<String, BaseSchemasError> {
251    let requested = format!("{revision}^{{commit}}");
252    let output = git(
253        root,
254        &["rev-parse", "--verify", "--end-of-options", &requested],
255    )
256    .output()
257    .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
258    if !output.status.success() {
259        return Err(BaseSchemasError::Git(BaseError::revision()));
260    }
261    let oid = std::str::from_utf8(&output.stdout)
262        .ok()
263        .map(str::trim)
264        .filter(|oid| valid_oid(oid))
265        .ok_or_else(|| BaseSchemasError::Git(BaseError::revision()))?;
266    Ok(oid.to_owned())
267}
268
269fn read_schema(
270    root: &Path,
271    oid: &str,
272    plan: &GenerationPlan,
273) -> Result<Option<Vec<u8>>, BaseSchemasError> {
274    // Git's `<tree>:./<path>` spelling resolves from the command's working directory. Without
275    // `./`, object paths are always repository-root-relative and nested managed workspaces break.
276    let object = format!("{oid}:./{}", plan.schema_path().as_str());
277    let listed = git(
278        root,
279        &[
280            "ls-tree",
281            "--name-only",
282            "-z",
283            oid,
284            "--",
285            plan.schema_path().as_str(),
286        ],
287    )
288    .output()
289    .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
290    if !listed.status.success() {
291        return Err(BaseSchemasError::Git(BaseError::schema(plan)));
292    }
293    if listed.stdout.is_empty() {
294        return Ok(None);
295    }
296    let mut expected = plan.schema_path().as_str().as_bytes().to_vec();
297    expected.push(0);
298    if listed.stdout != expected {
299        return Err(BaseSchemasError::Git(BaseError::schema(plan)));
300    }
301    let exists = git(root, &["cat-file", "-e", &object])
302        .output()
303        .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
304    if !exists.status.success() {
305        return Err(BaseSchemasError::Git(BaseError::schema(plan)));
306    }
307    let output = git(root, &["cat-file", "blob", &object])
308        .output()
309        .map_err(|_| BaseSchemasError::Tool(GitToolError))?;
310    if !output.status.success() {
311        return Err(BaseSchemasError::Git(BaseError::schema(plan)));
312    }
313    Ok(Some(output.stdout))
314}
315
316/// Base-revision packages, changed paths, and tree object index for diff ownership.
317#[derive(Debug)]
318pub struct BaseDiffInputs {
319    packages: Vec<Package>,
320    changed: Vec<RelativePath>,
321    bootstrapping: bool,
322    /// Mode/type class and object id for every validated path, including gitlinks.
323    objects: BTreeMap<RelativePath, (TreeKind, String)>,
324}
325impl BaseDiffInputs {
326    /// Returns packages discovered solely from base-revision declarations.
327    pub fn packages(&self) -> &[Package] {
328        &self.packages
329    }
330    /// Returns the sorted, deduplicated changed-path set.
331    pub fn changed(&self) -> &[RelativePath] {
332        &self.changed
333    }
334    /// Returns whether candidate declarations were selected for a wholly absent base root.
335    pub fn is_bootstrapping(&self) -> bool {
336        self.bootstrapping
337    }
338    /// Loads base/candidate bytes for accountable changed `Cargo.toml` paths under `ownership`.
339    ///
340    /// # Errors
341    /// Returns `BXW0104`/`BXW0106` for unreadable base blobs or non-regular candidate paths.
342    pub fn manifest_changes(
343        &self,
344        root: &Path,
345        ownership: &DiffOwnership,
346    ) -> Result<Vec<CargoManifestChange>, BaseInputsError> {
347        let Some(accountable) = ownership.accountable() else {
348            return Ok(Vec::new());
349        };
350        if ownership
351            .classifications()
352            .iter()
353            .all(|held| held.path().as_str() != "Cargo.lock")
354        {
355            return Ok(Vec::new());
356        }
357        ownership
358            .classifications()
359            .iter()
360            .filter(|held| {
361                held.package() == accountable
362                    && held.path().as_str().rsplit('/').next() == Some("Cargo.toml")
363            })
364            .map(|held| {
365                let path = held.path();
366                let base = match self.objects.get(path) {
367                    Some((TreeKind::Gitlink, _)) | None => None,
368                    Some((_, oid)) => Some(read_blob(root, oid, path)?),
369                };
370                Ok(CargoManifestChange::new(
371                    path.clone(),
372                    base,
373                    read_candidate_cargo(root, path)?,
374                ))
375            })
376            .collect()
377    }
378}
379
380/// Loads base packages, the working-tree changed set, and the full tree object index.
381///
382/// Untracked/ignored paths stay outside the Git changed set at V0 until staged or committed.
383///
384/// # Errors
385/// Returns coded listing/blob/discovery failures or [`GitToolError`] when Git cannot start.
386pub fn base_diff_inputs(
387    root: &Path,
388    base: &ResolvedBase,
389) -> Result<BaseDiffInputs, BaseInputsError> {
390    base_diff_inputs_inner(root, base, None)
391}
392
393/// Loads diff inputs, using candidate declarations only when the managed root has no base object.
394///
395/// This narrow bootstrap path lets a complete managed workspace be introduced below an existing
396/// repository. Once the base contains any object below `root`, base declarations remain the sole
397/// ownership authority.
398///
399/// # Errors
400/// Returns coded listing/blob/discovery failures or [`GitToolError`] when Git cannot start.
401pub fn base_diff_inputs_with_candidate(
402    root: &Path,
403    base: &ResolvedBase,
404    candidate_files: &[FileEntry],
405    candidate_manifests: &[(RelativePath, Vec<u8>)],
406) -> Result<BaseDiffInputs, BaseInputsError> {
407    base_diff_inputs_inner(
408        root,
409        base,
410        Some(CandidateDeclarations {
411            files: candidate_files,
412            manifests: candidate_manifests,
413        }),
414    )
415}
416
417struct CandidateDeclarations<'a> {
418    files: &'a [FileEntry],
419    manifests: &'a [(RelativePath, Vec<u8>)],
420}
421
422fn base_diff_inputs_inner(
423    root: &Path,
424    base: &ResolvedBase,
425    candidate: Option<CandidateDeclarations<'_>>,
426) -> Result<BaseDiffInputs, BaseInputsError> {
427    let listed = git_ok(root, &["ls-tree", "-r", "-z", base.as_str(), "--", "."])?;
428    let mut files = Vec::new();
429    let mut manifests = Vec::new();
430    let mut objects = BTreeMap::new();
431    for entry in parse_nul(&listed.stdout, parse_tree)? {
432        if objects
433            .insert(entry.path.clone(), (entry.kind, entry.oid.clone()))
434            .is_some()
435        {
436            return Err(data(BaseError::listing()));
437        }
438        // Gitlinks have no filesystem-walk counterpart at V0; retain them in the index only.
439        match entry.kind {
440            TreeKind::Gitlink => {}
441            TreeKind::File | TreeKind::Executable => {
442                if entry.path.as_str().rsplit('/').next() == Some("boxology.toml") {
443                    manifests.push((
444                        entry.path.clone(),
445                        read_blob(root, &entry.oid, &entry.path)?,
446                    ));
447                }
448                files.push(FileEntry::file(entry.path));
449            }
450            TreeKind::Symlink => {
451                let target = String::from_utf8(read_blob(root, &entry.oid, &entry.path)?)
452                    .map_err(|_| data(BaseError::blob(&entry.path)))?;
453                files.push(FileEntry::symlink(entry.path, target));
454            }
455        }
456    }
457    let diffed = git_ok(
458        root,
459        &[
460            "diff",
461            "--name-only",
462            "--relative",
463            "-z",
464            "--no-renames",
465            "--no-ext-diff",
466            base.as_str(),
467            "--",
468            ".",
469        ],
470    )?;
471    let mut changed = parse_nul(&diffed.stdout, parse_path)?;
472    changed.sort();
473    changed.dedup();
474    let bootstrapping = candidate.is_some() && objects.is_empty();
475    let (files, manifests) = match candidate.filter(|_| bootstrapping) {
476        Some(candidate) => (candidate.files.to_vec(), candidate.manifests.to_vec()),
477        None => (files, manifests),
478    };
479    let inputs =
480        WorkspaceInputs::new(files, manifests, "").map_err(|_| data(BaseError::listing()))?;
481    let (packages, findings) = inputs.discover();
482    if let Some(findings) = findings {
483        return Err(BaseInputsError::Declarations {
484            header: BaseError::declarations(),
485            findings,
486        });
487    }
488    Ok(BaseDiffInputs {
489        packages,
490        changed,
491        bootstrapping,
492        objects,
493    })
494}
495
496#[derive(Clone, Copy, Debug, Eq, PartialEq)]
497enum TreeKind {
498    File,
499    Executable,
500    Symlink,
501    Gitlink,
502}
503struct TreeEntry {
504    kind: TreeKind,
505    oid: String,
506    path: RelativePath,
507}
508
509fn data(error: BaseError) -> BaseInputsError {
510    BaseInputsError::Data(error)
511}
512fn git_ok(root: &Path, args: &[&str]) -> Result<Output, BaseInputsError> {
513    let output = git(root, args)
514        .output()
515        .map_err(|_| BaseInputsError::Tool(GitToolError))?;
516    output
517        .status
518        .success()
519        .then_some(output)
520        .ok_or_else(|| data(BaseError::listing()))
521}
522fn read_blob(root: &Path, oid: &str, path: &RelativePath) -> Result<Vec<u8>, BaseInputsError> {
523    let output = git(root, &["cat-file", "blob", oid])
524        .output()
525        .map_err(|_| BaseInputsError::Tool(GitToolError))?;
526    output
527        .status
528        .success()
529        .then_some(output.stdout)
530        .ok_or_else(|| data(BaseError::blob(path)))
531}
532fn read_candidate_cargo(
533    root: &Path,
534    path: &RelativePath,
535) -> Result<Option<Vec<u8>>, BaseInputsError> {
536    let at = root.join(path.as_str());
537    match fs::symlink_metadata(&at) {
538        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
539        Ok(meta) if meta.file_type().is_file() => fs::read(&at)
540            .map(Some)
541            .map_err(|_| data(BaseError::cargo(path))),
542        _ => Err(data(BaseError::cargo(path))),
543    }
544}
545fn parse_nul<T>(
546    stdout: &[u8],
547    parse: fn(&[u8]) -> Result<T, BaseInputsError>,
548) -> Result<Vec<T>, BaseInputsError> {
549    if stdout.is_empty() {
550        return Ok(Vec::new());
551    }
552    if stdout.last() != Some(&0) {
553        return Err(data(BaseError::listing()));
554    }
555    stdout[..stdout.len() - 1]
556        .split(|byte| *byte == 0)
557        .map(|record| {
558            if record.is_empty() {
559                Err(data(BaseError::listing()))
560            } else {
561                parse(record)
562            }
563        })
564        .collect()
565}
566fn parse_tree(record: &[u8]) -> Result<TreeEntry, BaseInputsError> {
567    let tab = record
568        .iter()
569        .position(|byte| *byte == b'\t')
570        .ok_or_else(|| data(BaseError::listing()))?;
571    let meta = std::str::from_utf8(&record[..tab]).map_err(|_| data(BaseError::listing()))?;
572    let mut parts = meta.split(' ');
573    let (Some(mode), Some(kind), Some(oid), None) =
574        (parts.next(), parts.next(), parts.next(), parts.next())
575    else {
576        return Err(data(BaseError::listing()));
577    };
578    if !valid_oid(oid) {
579        return Err(data(BaseError::listing()));
580    }
581    let kind = match (mode, kind) {
582        ("100644", "blob") => TreeKind::File,
583        ("100755", "blob") => TreeKind::Executable,
584        ("120000", "blob") => TreeKind::Symlink,
585        ("160000", "commit") => TreeKind::Gitlink,
586        _ => return Err(data(BaseError::listing())),
587    };
588    Ok(TreeEntry {
589        kind,
590        oid: oid.to_owned(),
591        path: parse_path(&record[tab + 1..])?,
592    })
593}
594fn parse_path(bytes: &[u8]) -> Result<RelativePath, BaseInputsError> {
595    let text = std::str::from_utf8(bytes).map_err(|_| data(BaseError::listing()))?;
596    RelativePath::new(text.to_owned()).map_err(|_| data(BaseError::listing()))
597}
598fn valid_oid(oid: &str) -> bool {
599    (oid.len() == 40 || oid.len() == 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())
600}
601fn git(root: &Path, args: &[&str]) -> Command {
602    let mut command = Command::new("git");
603    command.args(args).current_dir(root);
604    command
605}