1use std::path::{Component, Path, PathBuf};
2
3use crate::{
4 OutpostError, OutpostId, OutpostIdPrefix, OutpostResult, RegistryEntry, SourceRepo, safety,
5};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum OutpostSelector {
16 CliArg { cwd: PathBuf, value: PathBuf },
17 Path(PathBuf),
18}
19
20#[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}