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    /// Mode/type class and object id for every validated path, including gitlinks.
322    objects: BTreeMap<RelativePath, (TreeKind, String)>,
323}
324impl BaseDiffInputs {
325    /// Returns packages discovered solely from base-revision declarations.
326    pub fn packages(&self) -> &[Package] {
327        &self.packages
328    }
329    /// Returns the sorted, deduplicated changed-path set.
330    pub fn changed(&self) -> &[RelativePath] {
331        &self.changed
332    }
333    /// Loads base/candidate bytes for accountable changed `Cargo.toml` paths under `ownership`.
334    ///
335    /// # Errors
336    /// Returns `BXW0104`/`BXW0106` for unreadable base blobs or non-regular candidate paths.
337    pub fn manifest_changes(
338        &self,
339        root: &Path,
340        ownership: &DiffOwnership,
341    ) -> Result<Vec<CargoManifestChange>, BaseInputsError> {
342        let Some(accountable) = ownership.accountable() else {
343            return Ok(Vec::new());
344        };
345        if ownership
346            .classifications()
347            .iter()
348            .all(|held| held.path().as_str() != "Cargo.lock")
349        {
350            return Ok(Vec::new());
351        }
352        ownership
353            .classifications()
354            .iter()
355            .filter(|held| {
356                held.package() == accountable
357                    && held.path().as_str().rsplit('/').next() == Some("Cargo.toml")
358            })
359            .map(|held| {
360                let path = held.path();
361                let base = match self.objects.get(path) {
362                    Some((TreeKind::Gitlink, _)) | None => None,
363                    Some((_, oid)) => Some(read_blob(root, oid, path)?),
364                };
365                Ok(CargoManifestChange::new(
366                    path.clone(),
367                    base,
368                    read_candidate_cargo(root, path)?,
369                ))
370            })
371            .collect()
372    }
373}
374
375/// Loads base packages, the working-tree changed set, and the full tree object index.
376///
377/// Untracked/ignored paths stay outside the Git changed set at V0 until staged or committed.
378///
379/// # Errors
380/// Returns coded listing/blob/discovery failures or [`GitToolError`] when Git cannot start.
381pub fn base_diff_inputs(
382    root: &Path,
383    base: &ResolvedBase,
384) -> Result<BaseDiffInputs, BaseInputsError> {
385    let listed = git_ok(root, &["ls-tree", "-r", "-z", base.as_str(), "--", "."])?;
386    let mut files = Vec::new();
387    let mut manifests = Vec::new();
388    let mut objects = BTreeMap::new();
389    for entry in parse_nul(&listed.stdout, parse_tree)? {
390        if objects
391            .insert(entry.path.clone(), (entry.kind, entry.oid.clone()))
392            .is_some()
393        {
394            return Err(data(BaseError::listing()));
395        }
396        // Gitlinks have no filesystem-walk counterpart at V0; retain them in the index only.
397        match entry.kind {
398            TreeKind::Gitlink => {}
399            TreeKind::File | TreeKind::Executable => {
400                if entry.path.as_str().rsplit('/').next() == Some("boxology.toml") {
401                    manifests.push((
402                        entry.path.clone(),
403                        read_blob(root, &entry.oid, &entry.path)?,
404                    ));
405                }
406                files.push(FileEntry::file(entry.path));
407            }
408            TreeKind::Symlink => {
409                let target = String::from_utf8(read_blob(root, &entry.oid, &entry.path)?)
410                    .map_err(|_| data(BaseError::blob(&entry.path)))?;
411                files.push(FileEntry::symlink(entry.path, target));
412            }
413        }
414    }
415    let diffed = git_ok(
416        root,
417        &[
418            "diff",
419            "--name-only",
420            "--relative",
421            "-z",
422            "--no-renames",
423            "--no-ext-diff",
424            base.as_str(),
425            "--",
426            ".",
427        ],
428    )?;
429    let mut changed = parse_nul(&diffed.stdout, parse_path)?;
430    changed.sort();
431    changed.dedup();
432    let inputs =
433        WorkspaceInputs::new(files, manifests, "").map_err(|_| data(BaseError::listing()))?;
434    let (packages, findings) = inputs.discover();
435    if let Some(findings) = findings {
436        return Err(BaseInputsError::Declarations {
437            header: BaseError::declarations(),
438            findings,
439        });
440    }
441    Ok(BaseDiffInputs {
442        packages,
443        changed,
444        objects,
445    })
446}
447
448#[derive(Clone, Copy, Debug, Eq, PartialEq)]
449enum TreeKind {
450    File,
451    Executable,
452    Symlink,
453    Gitlink,
454}
455struct TreeEntry {
456    kind: TreeKind,
457    oid: String,
458    path: RelativePath,
459}
460
461fn data(error: BaseError) -> BaseInputsError {
462    BaseInputsError::Data(error)
463}
464fn git_ok(root: &Path, args: &[&str]) -> Result<Output, BaseInputsError> {
465    let output = git(root, args)
466        .output()
467        .map_err(|_| BaseInputsError::Tool(GitToolError))?;
468    output
469        .status
470        .success()
471        .then_some(output)
472        .ok_or_else(|| data(BaseError::listing()))
473}
474fn read_blob(root: &Path, oid: &str, path: &RelativePath) -> Result<Vec<u8>, BaseInputsError> {
475    let output = git(root, &["cat-file", "blob", oid])
476        .output()
477        .map_err(|_| BaseInputsError::Tool(GitToolError))?;
478    output
479        .status
480        .success()
481        .then_some(output.stdout)
482        .ok_or_else(|| data(BaseError::blob(path)))
483}
484fn read_candidate_cargo(
485    root: &Path,
486    path: &RelativePath,
487) -> Result<Option<Vec<u8>>, BaseInputsError> {
488    let at = root.join(path.as_str());
489    match fs::symlink_metadata(&at) {
490        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
491        Ok(meta) if meta.file_type().is_file() => fs::read(&at)
492            .map(Some)
493            .map_err(|_| data(BaseError::cargo(path))),
494        _ => Err(data(BaseError::cargo(path))),
495    }
496}
497fn parse_nul<T>(
498    stdout: &[u8],
499    parse: fn(&[u8]) -> Result<T, BaseInputsError>,
500) -> Result<Vec<T>, BaseInputsError> {
501    if stdout.is_empty() {
502        return Ok(Vec::new());
503    }
504    if stdout.last() != Some(&0) {
505        return Err(data(BaseError::listing()));
506    }
507    stdout[..stdout.len() - 1]
508        .split(|byte| *byte == 0)
509        .map(|record| {
510            if record.is_empty() {
511                Err(data(BaseError::listing()))
512            } else {
513                parse(record)
514            }
515        })
516        .collect()
517}
518fn parse_tree(record: &[u8]) -> Result<TreeEntry, BaseInputsError> {
519    let tab = record
520        .iter()
521        .position(|byte| *byte == b'\t')
522        .ok_or_else(|| data(BaseError::listing()))?;
523    let meta = std::str::from_utf8(&record[..tab]).map_err(|_| data(BaseError::listing()))?;
524    let mut parts = meta.split(' ');
525    let (Some(mode), Some(kind), Some(oid), None) =
526        (parts.next(), parts.next(), parts.next(), parts.next())
527    else {
528        return Err(data(BaseError::listing()));
529    };
530    if !valid_oid(oid) {
531        return Err(data(BaseError::listing()));
532    }
533    let kind = match (mode, kind) {
534        ("100644", "blob") => TreeKind::File,
535        ("100755", "blob") => TreeKind::Executable,
536        ("120000", "blob") => TreeKind::Symlink,
537        ("160000", "commit") => TreeKind::Gitlink,
538        _ => return Err(data(BaseError::listing())),
539    };
540    Ok(TreeEntry {
541        kind,
542        oid: oid.to_owned(),
543        path: parse_path(&record[tab + 1..])?,
544    })
545}
546fn parse_path(bytes: &[u8]) -> Result<RelativePath, BaseInputsError> {
547    let text = std::str::from_utf8(bytes).map_err(|_| data(BaseError::listing()))?;
548    RelativePath::new(text.to_owned()).map_err(|_| data(BaseError::listing()))
549}
550fn valid_oid(oid: &str) -> bool {
551    (oid.len() == 40 || oid.len() == 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())
552}
553fn git(root: &Path, args: &[&str]) -> Command {
554    let mut command = Command::new("git");
555    command.args(args).current_dir(root);
556    command
557}