1pub mod compat;
12mod constraint;
13mod context;
14mod filter;
15mod package;
16mod path;
17mod skill;
18mod types;
19mod version;
20
21use std::collections::HashMap;
22use std::path::Path;
23
24#[cfg(test)]
25use indexmap::IndexMap;
26
27pub use constraint::parse_version_constraint;
28pub use context::ResolverContext;
29pub use types::*;
30
31pub(crate) use package::{PackageResolutionState, PendingSource, RegisteredPackage};
32#[cfg(test)]
33pub(crate) use path::apply_subpath;
34
35use crate::config::{EffectiveConfig, Manifest, SourceSpec};
36use crate::diagnostic::DiagnosticCollector;
37use crate::error::{MarsError, ResolutionError};
38use crate::lock::LockFile;
39use crate::source::{AvailableVersion, ResolvedRef};
40use crate::types::SourceName;
41use crate::types::SourceUrl;
42use filter::is_item_excluded;
43use package::resolve_package_bottom_up;
44use skill::{parse_pending_item_skill_deps, resolve_skill_ref};
45use version::validate_all_constraints;
46
47#[derive(Debug)]
48enum VersionAction {
49 Process,
50 Skip,
51}
52
53fn apply_item_version_policy(
54 pending_item: &PendingItem,
55 check: VersionCheckResult,
56 diag: &mut DiagnosticCollector,
57) -> Result<VersionAction, ResolutionError> {
58 match check {
59 VersionCheckResult::NotSeen => Ok(VersionAction::Process),
60 VersionCheckResult::SameVersion => Ok(VersionAction::Skip),
61 VersionCheckResult::PotentiallyConflicting {
62 existing,
63 requested,
64 } => {
65 diag.warn(
66 "potential-version-drift",
67 format!(
68 "potential version drift: item '{}' from '{}' requested as {} but already seen as {}",
69 pending_item.item, pending_item.package, requested, existing
70 ),
71 );
72 Ok(VersionAction::Skip)
73 }
74 VersionCheckResult::DifferentVersion {
75 existing,
76 requested,
77 } => {
78 if pending_item.is_local {
79 return Ok(VersionAction::Skip);
80 }
81 Err(ResolutionError::ItemVersionConflict {
82 item: pending_item.item.to_string(),
83 package: pending_item.package.to_string(),
84 existing: existing.to_string(),
85 requested: requested.to_string(),
86 chain: pending_item.required_by.clone(),
87 })
88 }
89 }
90}
91
92fn same_resolved_ref(a: &ResolvedRef, b: &ResolvedRef) -> bool {
93 a.version == b.version
94 && a.version_tag == b.version_tag
95 && a.commit == b.commit
96 && a.tree_path == b.tree_path
97}
98
99fn describe_resolved_ref(resolved: &ResolvedRef) -> String {
100 let version = resolved
101 .version_tag
102 .clone()
103 .or_else(|| resolved.version.as_ref().map(ToString::to_string))
104 .unwrap_or_else(|| "no-version".to_string());
105 let commit = resolved.commit.as_deref().unwrap_or("no-commit");
106 format!("{version}@{commit}")
107}
108
109pub trait VersionLister {
111 fn list_versions(&self, url: &SourceUrl) -> Result<Vec<AvailableVersion>, MarsError>;
112}
113
114pub trait SourceFetcher {
116 fn fetch_git_version(
118 &self,
119 url: &SourceUrl,
120 version: &AvailableVersion,
121 source_name: &str,
122 preferred_commit: Option<&str>,
123 diag: &mut DiagnosticCollector,
124 ) -> Result<ResolvedRef, MarsError>;
125
126 fn fetch_git_ref(
128 &self,
129 url: &SourceUrl,
130 ref_name: &str,
131 source_name: &str,
132 preferred_commit: Option<&str>,
133 diag: &mut DiagnosticCollector,
134 ) -> Result<ResolvedRef, MarsError>;
135
136 fn fetch_path(
138 &self,
139 path: &Path,
140 source_name: &str,
141 diag: &mut DiagnosticCollector,
142 ) -> Result<ResolvedRef, MarsError>;
143}
144
145pub trait ManifestReader {
147 fn read_manifest(
148 &self,
149 source_tree: &Path,
150 diag: &mut DiagnosticCollector,
151 ) -> Result<Option<Manifest>, MarsError>;
152}
153
154pub trait SourceProvider: VersionLister + SourceFetcher + ManifestReader {}
156
157impl<T> SourceProvider for T where T: VersionLister + SourceFetcher + ManifestReader {}
158
159pub fn resolve(
192 config: &EffectiveConfig,
193 provider: &dyn SourceProvider,
194 locked: Option<&LockFile>,
195 options: &ResolveOptions,
196 diag: &mut DiagnosticCollector,
197) -> Result<ResolvedGraph, MarsError> {
198 let direct_source_names: std::collections::HashSet<SourceName> =
200 config.dependencies.keys().cloned().collect();
201
202 let direct_requests: Vec<PendingSource> = {
204 let mut reqs = Vec::new();
205 for (name, source) in &config.dependencies {
206 let is_upgrade_target = options.maximize
207 && (options.upgrade_targets.is_empty() || options.upgrade_targets.contains(name));
208 let constraint = match &source.spec {
209 SourceSpec::Git(git) => {
210 if options.bump_direct_constraints && is_upgrade_target {
211 VersionConstraint::Latest
212 } else {
213 parse_version_constraint(git.version.as_deref())
214 }
215 }
216 SourceSpec::Path(_) => VersionConstraint::Latest,
217 };
218 reqs.push(PendingSource {
219 name: name.clone(),
220 source_id: source.id.clone(),
221 spec: source.spec.clone(),
222 subpath: source.subpath.clone(),
223 constraint,
224 filter: source.filter.clone(),
225 required_by: "mars.toml".to_string(),
226 });
227 }
228 reqs
229 };
230
231 let mut version_overrides: HashMap<
234 SourceName,
235 (ResolvedRef, RootedSourceRef, Option<semver::Version>),
236 > = HashMap::new();
237 let mut restart_history: HashMap<SourceName, Vec<ResolvedRef>> = HashMap::new();
239
240 let ctx = loop {
244 let mut ctx = ResolverContext::new();
245 ctx.set_direct_sources(direct_source_names.clone());
246 ctx.set_version_overrides(version_overrides.clone());
247
248 let bottom_up_result = (|| -> Result<(), MarsError> {
250 for request in direct_requests
251 .iter()
252 .filter(|request| filter::is_unfiltered_request(&request.filter))
253 {
254 resolve_package_bottom_up(
255 request, true, provider, locked, options, diag, &mut ctx,
256 )?;
257 }
258 for request in direct_requests
259 .iter()
260 .filter(|request| !filter::is_unfiltered_request(&request.filter))
261 {
262 resolve_package_bottom_up(
263 request, true, provider, locked, options, diag, &mut ctx,
264 )?;
265 }
266 Ok(())
267 })();
268
269 match bottom_up_result {
270 Err(MarsError::ResolutionRestartNeeded { package }) => {
271 let Some((pkg_name, new_ref, new_rooted, latest_version)) =
273 ctx.take_pending_restart()
274 else {
275 return Err(MarsError::Internal(format!(
276 "missing pending restart payload for `{package}`"
277 )));
278 };
279 let history = restart_history.entry(pkg_name.clone()).or_default();
280 if let Some(cycle_start) = history
281 .iter()
282 .position(|seen| same_resolved_ref(seen, &new_ref))
283 {
284 let mut cycle: Vec<String> = history[cycle_start..]
285 .iter()
286 .map(describe_resolved_ref)
287 .collect();
288 cycle.push(describe_resolved_ref(&new_ref));
289 return Err(MarsError::Resolution(ResolutionError::VersionConflict {
290 name: pkg_name.to_string(),
291 message: format!(
292 "resolution oscillation detected for `{pkg_name}`: {}",
293 cycle.join(" -> ")
294 ),
295 }));
296 }
297 history.push(new_ref.clone());
298 version_overrides.insert(pkg_name, (new_ref, new_rooted, latest_version));
299 continue;
301 }
302 Err(other) => return Err(other),
303 Ok(()) => break ctx,
304 }
305 };
306
307 let mut ctx = ctx;
309 while let Some(pending_item) = ctx.pop_pending() {
310 let (resolved_ref, skill_deps) = {
311 let Some(package) = ctx.registry().get(&pending_item.package) else {
312 return Err(ResolutionError::SourceNotFound {
313 name: pending_item.package.to_string(),
314 }
315 .into());
316 };
317
318 if package
319 .item(pending_item.kind, &pending_item.item)
320 .is_none()
321 {
322 continue;
323 }
324
325 let skill_deps = parse_pending_item_skill_deps(&pending_item, package)?;
326 (package.node.resolved_ref.clone(), skill_deps)
327 };
328
329 match apply_item_version_policy(
330 &pending_item,
331 ctx.visited().check_version(
332 &pending_item.package,
333 &pending_item.item,
334 &pending_item.constraint,
335 ),
336 diag,
337 )
338 .map_err(MarsError::from)?
339 {
340 VersionAction::Process => {}
341 VersionAction::Skip => continue,
342 }
343
344 ctx.package_versions_mut()
345 .check_or_insert(
346 &pending_item.package,
347 &resolved_ref,
348 &pending_item.constraint,
349 &pending_item.required_by,
350 pending_item.is_local,
351 )
352 .map_err(MarsError::from)?;
353
354 ctx.visited_mut().insert(
355 pending_item.package.clone(),
356 pending_item.item.clone(),
357 pending_item.constraint.clone(),
358 resolved_ref,
359 );
360
361 for skill_dep in skill_deps {
362 let resolved_skill = resolve_skill_ref(
363 &skill_dep,
364 &pending_item,
365 ctx.registry(),
366 ctx.version_constraints(),
367 )?;
368 if is_item_excluded(
369 ctx.materialization_filters(),
370 ctx.registry(),
371 &resolved_skill.package,
372 resolved_skill.kind,
373 &resolved_skill.item,
374 ) {
375 continue;
376 }
377 ctx.add_filter(
378 &resolved_skill.package,
379 crate::config::FilterMode::Include {
380 agents: Vec::new(),
381 skills: vec![resolved_skill.item.clone()],
382 },
383 );
384 ctx.push_pending(resolved_skill);
385 }
386 }
387
388 let version_constraints = ctx.version_constraints().clone();
389 let graph = ctx.into_graph();
390
391 validate_all_constraints(&graph.nodes, &version_constraints)?;
392
393 Ok(graph)
394}
395
396#[cfg(test)]
397fn alphabetical_order(nodes: &IndexMap<SourceName, ResolvedNode>) -> Vec<SourceName> {
398 let mut order: Vec<SourceName> = nodes.keys().cloned().collect();
399 order.sort();
400 order
401}
402
403#[cfg(test)]
404mod tests;