Skip to main content

backhopper_core/compat/
target_tree.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Per-touched-path classification against an optional target-branch
6//! tree. Pure data; no I/O. CLI builds the inputs (`TargetTreeIndex`
7//! plus `PathTranslations`) and merges the result into each pin's
8//! verdict.
9
10use std::path::{Path, PathBuf};
11
12use crate::compat::target_tree_index::TargetTreeIndex;
13use crate::config::{PathTranslation, PathTranslations, TranslationOrigin};
14use crate::model::verdict::TranslationSource;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum TargetPathClassification {
18    /// Path appears on the target tree as-is. No action.
19    OnTarget,
20    /// Path is absent on the target tree but a translation rewrites
21    /// it to a path that does appear there.
22    TranslatesTo {
23        target_path: PathBuf,
24        translation: TranslationSource,
25    },
26    /// Path is absent on the target tree and no translation matches.
27    MissingFromTarget,
28    /// Path was rejected at normalisation time (contained `..` or
29    /// other unsafe shape). Skip without flagging.
30    UnsafePath,
31    /// Patch deletes this path; target-tree existence is irrelevant.
32    DeletedByPatch,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct TouchedPathQuery<'a> {
37    pub path: &'a Path,
38    pub is_deletion: bool,
39}
40
41pub fn classify_path(
42    query: TouchedPathQuery<'_>,
43    index: &TargetTreeIndex,
44    translations: &PathTranslations,
45) -> TargetPathClassification {
46    if query.is_deletion {
47        return TargetPathClassification::DeletedByPatch;
48    }
49    let Some(normalised) = normalise(query.path) else {
50        return TargetPathClassification::UnsafePath;
51    };
52    if index.contains_path(&normalised) {
53        return TargetPathClassification::OnTarget;
54    }
55    let path_str = normalised.to_string_lossy();
56    if let Some((rewritten, translation)) = translations.translate(&path_str) {
57        if index.contains_path(&rewritten) {
58            return TargetPathClassification::TranslatesTo {
59                target_path: rewritten,
60                translation: to_verdict_translation_source(translation),
61            };
62        }
63    }
64    TargetPathClassification::MissingFromTarget
65}
66
67fn to_verdict_translation_source(entry: &PathTranslation) -> TranslationSource {
68    match &entry.origin {
69        TranslationOrigin::ConfigStanza => TranslationSource::ConfigStanza {
70            name: entry.name.clone(),
71        },
72        TranslationOrigin::ExternalFile { path } => TranslationSource::ExternalFile {
73            path: path.clone(),
74            name: entry.name.clone(),
75        },
76    }
77}
78
79/// Apply the path normalisation rules: backslash to
80/// forward slash, strip leading `./`, reject `..` segments. Returns
81/// `None` for unsafe paths.
82pub fn normalise(p: &Path) -> Option<PathBuf> {
83    let raw = p.to_string_lossy();
84    let mut s: String = raw.replace('\\', "/");
85    while s.starts_with("./") {
86        s.drain(..2);
87    }
88    if s.split('/').any(|seg| seg == "..") {
89        return None;
90    }
91    Some(PathBuf::from(s))
92}