Skip to main content

outpost_core/
selector.rs

1use std::path::{Component, Path, PathBuf};
2
3use crate::{
4    OutpostError, OutpostId, OutpostIdPrefix, OutpostResult, RegistryEntry, SourceRepo, safety,
5};
6
7/// User-supplied `<outpost>` selector for source-scoped operations.
8///
9/// A selector may be a path or a Docker-style outpost ID prefix. ID prefixes
10/// are derived from source path plus outpost path, scoped to one source
11/// registry, and accepted only when unique. If a bare hex token resolves as
12/// both a path and an ID for different entries, the selector is ambiguous and
13/// must fail closed instead of picking precedence.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum OutpostSelector {
16    CliArg { cwd: PathBuf, value: PathBuf },
17    Path(PathBuf),
18}
19
20/// Registry entry resolved from an `OutpostSelector`.
21///
22/// The resolved registry entry and canonical registry path for an
23/// `OutpostSelector`.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ResolvedOutpostEntry {
26    pub entry: RegistryEntry,
27    pub path: PathBuf,
28}
29
30impl OutpostSelector {
31    pub fn from_cli_arg(cwd: &Path, value: PathBuf) -> Self {
32        Self::CliArg {
33            cwd: cwd.to_path_buf(),
34            value,
35        }
36    }
37
38    pub fn from_path(path: PathBuf) -> Self {
39        Self::Path(path)
40    }
41
42    fn display_value(&self) -> String {
43        match self {
44            Self::CliArg { value, .. } | Self::Path(value) => value.to_string_lossy().into_owned(),
45        }
46    }
47}
48
49pub fn resolve_entry(
50    source: &SourceRepo,
51    selector: &OutpostSelector,
52) -> OutpostResult<ResolvedOutpostEntry> {
53    let registry = source.registry()?;
54    resolve_entry_in_entries(source.work_tree(), registry.entries(), selector)
55}
56
57pub fn resolve_live_entry(
58    source: &SourceRepo,
59    selector: &OutpostSelector,
60) -> OutpostResult<ResolvedOutpostEntry> {
61    let resolved = resolve_entry(source, selector)?;
62    safety::check_entry_is_managed_outpost_of(source, &resolved.entry)?;
63    Ok(resolved)
64}
65
66pub(crate) fn resolve_entry_in_entries(
67    source_path: &Path,
68    entries: &[RegistryEntry],
69    selector: &OutpostSelector,
70) -> OutpostResult<ResolvedOutpostEntry> {
71    let classified = classify(selector);
72    match classified {
73        ClassifiedSelector::PathOnly(path) => resolve_path(entries, &path),
74        ClassifiedSelector::BarePath(path) => resolve_path(entries, &path),
75        ClassifiedSelector::BareHex { path, prefix } => {
76            let path_match = find_by_path(entries, &path)?;
77            let id_match = find_by_prefix(source_path, entries, &prefix)?;
78            match (path_match, id_match) {
79                (Some(path_entry), Some(id_entry)) if path_entry.path == id_entry.path => {
80                    Ok(resolved(path_entry))
81                }
82                (Some(_), Some(_)) => Err(OutpostError::OutpostSelectorAmbiguous(
83                    selector.display_value(),
84                )),
85                (Some(path_entry), None) => Ok(resolved(path_entry)),
86                (None, Some(id_entry)) => Ok(resolved(id_entry)),
87                (None, None) => Err(OutpostError::OutpostIdPrefixNotFound(
88                    prefix.as_str().to_owned(),
89                )),
90            }
91        }
92    }
93}
94
95enum ClassifiedSelector {
96    PathOnly(PathBuf),
97    BarePath(PathBuf),
98    BareHex {
99        path: PathBuf,
100        prefix: OutpostIdPrefix,
101    },
102}
103
104fn classify(selector: &OutpostSelector) -> ClassifiedSelector {
105    match selector {
106        OutpostSelector::Path(path) => ClassifiedSelector::PathOnly(path.clone()),
107        OutpostSelector::CliArg { cwd, value } => {
108            if explicit_path_syntax(value) {
109                return ClassifiedSelector::PathOnly(absolutize(cwd, value));
110            }
111            let path = absolutize(cwd, value);
112            let Some(value) = value.to_str() else {
113                return ClassifiedSelector::PathOnly(path);
114            };
115            match OutpostIdPrefix::parse(value.to_owned()) {
116                Ok(prefix) => ClassifiedSelector::BareHex { path, prefix },
117                Err(_) => ClassifiedSelector::BarePath(path),
118            }
119        }
120    }
121}
122
123fn explicit_path_syntax(path: &Path) -> bool {
124    if path.is_absolute() || path.to_str().is_none() {
125        return true;
126    }
127    let mut components = path.components();
128    match (components.next(), components.next()) {
129        (Some(Component::CurDir | Component::ParentDir), _) => true,
130        (Some(_), Some(_)) => true,
131        _ => false,
132    }
133}
134
135fn absolutize(cwd: &Path, path: &Path) -> PathBuf {
136    if path.is_absolute() {
137        path.to_path_buf()
138    } else {
139        cwd.join(path)
140    }
141}
142
143fn resolve_path(entries: &[RegistryEntry], path: &Path) -> OutpostResult<ResolvedOutpostEntry> {
144    find_by_path(entries, path)?
145        .map(resolved)
146        .ok_or_else(|| OutpostError::RegistryEntryNotFound(canonicalize_existing_or_missing(path)))
147}
148
149fn find_by_path<'a>(
150    entries: &'a [RegistryEntry],
151    path: &Path,
152) -> OutpostResult<Option<&'a RegistryEntry>> {
153    let path = canonicalize_existing_or_missing(path);
154    Ok(entries.iter().find(|entry| entry.path == path))
155}
156
157fn find_by_prefix<'a>(
158    source_path: &Path,
159    entries: &'a [RegistryEntry],
160    prefix: &OutpostIdPrefix,
161) -> OutpostResult<Option<&'a RegistryEntry>> {
162    let mut matches = entries
163        .iter()
164        .filter(|entry| OutpostId::derive(source_path, &entry.path).starts_with(prefix));
165    let first = matches.next();
166    if matches.next().is_some() {
167        return Err(OutpostError::OutpostIdPrefixAmbiguous(
168            prefix.as_str().to_owned(),
169        ));
170    }
171    Ok(first)
172}
173
174fn resolved(entry: &RegistryEntry) -> ResolvedOutpostEntry {
175    ResolvedOutpostEntry {
176        entry: entry.clone(),
177        path: entry.path.clone(),
178    }
179}
180
181fn canonicalize_existing_or_missing(path: &Path) -> PathBuf {
182    if path.exists() {
183        return std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
184    }
185
186    let mut missing = Vec::new();
187    let mut existing = path;
188    while !existing.exists() {
189        let Some(name) = existing.file_name() else {
190            return normalize_existing_or_missing(path);
191        };
192        missing.push(name.to_os_string());
193        let Some(parent) = existing.parent() else {
194            return normalize_existing_or_missing(path);
195        };
196        existing = parent;
197    }
198
199    let mut canonical =
200        std::fs::canonicalize(existing).unwrap_or_else(|_| normalize_existing_or_missing(existing));
201    for component in missing.iter().rev() {
202        canonical.push(component);
203    }
204    normalize_existing_or_missing(&canonical)
205}
206
207fn normalize_existing_or_missing(path: &Path) -> PathBuf {
208    let mut normalized = PathBuf::new();
209    for component in path.components() {
210        match component {
211            Component::CurDir => {}
212            Component::ParentDir => {
213                normalized.pop();
214            }
215            other => normalized.push(other.as_os_str()),
216        }
217    }
218    normalized
219}