1use std::fmt::Write as _;
9
10use camino::Utf8PathBuf;
11use semver::Version;
12use serde::Serialize;
13
14use crate::cli::self_depend::{
15 AddArgs, Caller, CleanArgs, SelfDependArgs, SelfDependCommand, StatusArgs, SyncArgs,
16};
17use crate::context::AppContext;
18use crate::domain::paths::{CI_VAR, SELF_DEPEND_OFF_VAR, UserEnv};
19use crate::error::AppError;
20use crate::output;
21use crate::release::crates_io::CratesIoResolver;
22use crate::self_depend::fragments::{self, Fragment};
23use crate::self_depend::leftovers::{self, Kept, Leftover};
24use crate::self_depend::manager::{self, Detected, Manager};
25use crate::self_depend::status::{self, Freshness, Presence, Report, State};
26use crate::self_depend::txn::{self, Moved};
27use crate::self_depend::venue::{self, Venue, Verdict};
28use crate::self_depend::{ENVRC, parse_tag, stamp};
29
30pub fn run(ctx: &AppContext, args: SelfDependArgs) -> Result<(), AppError> {
36 match args.command {
37 SelfDependCommand::Status(args) => run_status(ctx, args),
38 SelfDependCommand::Add(args) => run_add(ctx, args),
39 SelfDependCommand::Sync(args) => run_sync(ctx, args),
40 SelfDependCommand::Clean(args) => run_clean(ctx, args),
41 }
42}
43
44fn resolve_target(ctx: &AppContext, target: Utf8PathBuf) -> Result<Utf8PathBuf, AppError> {
46 if target.is_absolute() {
47 Ok(target)
48 } else if target == "." {
49 Ok(ctx.cwd.clone())
50 } else {
51 Err(AppError::Usage("target must be absolute or .".to_string()))
52 }
53}
54
55fn this_version() -> Version {
57 env!("CARGO_PKG_VERSION")
58 .parse()
59 .unwrap_or_else(|_| Version::new(0, 0, 0))
60}
61
62const fn presence_word(presence: Presence) -> &'static str {
63 match presence {
64 Presence::Present => "present",
65 Presence::Absent => "absent",
66 }
67}
68
69fn run_status(ctx: &AppContext, args: StatusArgs) -> Result<(), AppError> {
77 let target = resolve_target(ctx, args.target)?;
78 let mut report = status::report(&target, &UserEnv::from_process(), &this_version());
79 if let Some(manager) = args.manager {
80 report.managers.retain(|entry| entry.manager == manager);
81 }
82 if args.json {
83 return output::json(&report);
84 }
85 render_status(&report);
86 Ok(())
87}
88
89fn render_status(report: &Report) {
90 let state = match report.state {
91 State::Ready => "ready",
92 State::Unwired => "unwired",
93 State::LineAbsent => "line-absent",
94 State::Ambiguous => "ambiguous",
95 State::Leftovers => "leftovers",
96 };
97 output::line(format!("state {state}"));
98 if let Some(wired) = report.wired {
99 output::line(format!("wired through {wired}"));
100 }
101 for entry in &report.managers {
102 let mut line = format!("manager {} ", entry.manager);
103 match entry.file.as_ref() {
104 Some(file) => {
105 let _ = write!(line, "present ({file})");
106 }
107 None => line.push_str("absent"),
108 }
109 if let Some(version) = entry.version.as_ref() {
110 let _ = write!(line, ", pinned {version}");
111 if let Some(freshness) = entry.freshness {
112 let word = match freshness {
113 Freshness::Current => "current with this binary",
114 Freshness::Behind => "behind this binary",
115 Freshness::Ahead => "ahead of this binary",
116 };
117 let _ = write!(line, " ({word})");
118 }
119 }
120 if let Some(lock) = entry.lock {
121 let _ = write!(line, ", lock {}", presence_word(lock));
122 }
123 if let Some(rev) = entry.locked_rev.as_ref() {
124 let _ = write!(line, ", locked at {rev}");
125 }
126 output::line(line);
127 }
128 output::line(format!(
129 "{ENVRC} {}, sync line {}",
130 presence_word(report.envrc),
131 if report.envrc_sync { "yes" } else { "no" }
132 ));
133 if let Some(stamp) = report.stamp.as_ref() {
134 output::line(format!("last sync attempt {stamp}"));
135 }
136 if report.off {
137 output::line(format!(
138 "the loop is switched off here: {CI_VAR} or {SELF_DEPEND_OFF_VAR} is set"
139 ));
140 }
141 output::line(format!(
142 "host nix {}, direnv {}",
143 if report.host.nix { "ok" } else { "absent" },
144 if report.host.direnv { "ok" } else { "absent" }
145 ));
146 for leftover in &report.leftovers {
147 output::line(format!("leftover {}: {}", leftover.file, leftover.reason));
148 }
149 output::line("Next:");
150 for line in &report.next {
151 output::line(format!(" {line}"));
152 }
153}
154
155#[derive(Debug, Serialize)]
159struct AddReport {
160 schema: &'static str,
161 target: Utf8PathBuf,
162 manager: Manager,
163 venue: Venue,
164 version: String,
165 #[serde(flatten)]
166 verdict: Verdict,
167 fragments: Vec<Fragment>,
168 seeds: Vec<Utf8PathBuf>,
169 applied: bool,
170}
171
172fn choose_manager(asked: Option<Manager>, detected: &[Detected]) -> Result<Manager, AppError> {
177 let wired = manager::wired(detected);
178 let names = |held: &[&Detected]| -> String {
179 held.iter()
180 .map(|held| held.manager.as_str())
181 .collect::<Vec<_>>()
182 .join(" and ")
183 };
184 match (asked, wired.as_slice()) {
185 (Some(asked), [held]) if held.manager != asked => Err(AppError::Refused(format!(
186 "{} already pins this tool in this target; one target runs one mechanism, so --manager {asked} is refused",
187 held.manager
188 ))),
189 (Some(asked), _) => Ok(asked),
190 (None, [held]) => Ok(held.manager),
191 (None, []) => {
192 let present: Vec<&Detected> =
193 detected.iter().filter(|held| held.file.is_some()).collect();
194 match present.as_slice() {
195 [] => Ok(Manager::Flake),
196 [one] => Ok(one.manager),
197 several => Err(AppError::Usage(format!(
198 "the target carries {}; name one with --manager",
199 names(several)
200 ))),
201 }
202 }
203 (None, several) => Err(AppError::Refused(format!(
204 "{} each pin this tool; one target runs one mechanism",
205 names(several)
206 ))),
207 }
208}
209
210fn run_add(ctx: &AppContext, args: AddArgs) -> Result<(), AppError> {
218 let target = resolve_target(ctx, args.target)?;
219 let version = match args.tag.as_deref() {
220 Some(tag) => parse_tag(tag).map_err(AppError::Usage)?,
221 None => this_version(),
222 };
223 let detected = manager::detect(&target);
224 let manager = choose_manager(args.manager, &detected)?;
225 let venue = match args.venue {
226 Some(venue) => venue,
227 None => venue::default_venue(manager).ok_or_else(|| {
228 AppError::Refused(format!(
229 "{manager} renders no fragment for any venue; every pair is manual"
230 ))
231 })?,
232 };
233 let verdict = venue::verdict(manager, venue);
234 if let Verdict::Manual { reason } = verdict {
235 return Err(AppError::Refused(format!(
236 "{manager} with {venue} is a manual pair: {}",
237 reason.as_str()
238 )));
239 }
240 let mut fragments = fragments::render(manager, venue, &version);
241 fragments.push(fragments::envrc_fragment());
242
243 let mut seeds: Vec<(Utf8PathBuf, String)> = Vec::new();
245 let manager_file_absent = detected
246 .iter()
247 .find(|held| held.manager == manager)
248 .is_none_or(|held| held.file.is_none());
249 if manager_file_absent && let Some((file, contents)) = fragments::seed(manager, venue, &version)
250 {
251 seeds.push((Utf8PathBuf::from(file), contents));
252 }
253 if manager == Manager::Flake && venue == Venue::Flake && !target.join(ENVRC).exists() {
254 seeds.push((Utf8PathBuf::from(ENVRC), fragments::envrc_seed()));
255 }
256 if args.apply {
257 for (file, contents) in &seeds {
258 crate::adapters::fs::write_atomic(&target.join(file), contents.as_bytes())?;
259 }
260 }
261 let report = AddReport {
262 schema: "sdd.self-depend-add/1",
263 target,
264 manager,
265 venue,
266 version: version.to_string(),
267 verdict,
268 fragments,
269 seeds: seeds.iter().map(|(file, _)| file.clone()).collect(),
270 applied: args.apply,
271 };
272 if args.json {
273 return output::json(&report);
274 }
275 render_add(&report);
276 Ok(())
277}
278
279fn render_add(report: &AddReport) {
280 output::line(format!(
281 "{} with {} at {}: renders",
282 report.manager, report.venue, report.version
283 ));
284 for fragment in &report.fragments {
285 output::line(format!(
286 "\n{}: {}, near `{}`",
287 fragment.file, fragment.placement, fragment.anchor
288 ));
289 output::line(fragment.text.trim_end());
290 }
291 for seed in &report.seeds {
292 output::line(format!(
293 "\n{seed}: {}",
294 if report.applied {
295 "seeded"
296 } else {
297 "would be seeded; pass --apply"
298 }
299 ));
300 }
301 if report.seeds.is_empty() {
302 output::line("\nnothing to seed: place the fragments in the files the project owns");
303 }
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
310#[serde(rename_all = "kebab-case")]
311enum Outcome {
312 Off,
314 Unwired,
316 Stamped,
318 Current,
320 Pending,
322 Moved,
324}
325
326#[derive(Debug, Serialize)]
328struct SyncReport {
329 schema: &'static str,
330 target: Utf8PathBuf,
331 caller: &'static str,
332 outcome: Outcome,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 manager: Option<Manager>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 from: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 to: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 moved: Option<Moved>,
341 message: String,
342}
343
344impl SyncReport {
345 fn new(target: &Utf8PathBuf, caller: Caller, outcome: Outcome, message: String) -> Self {
346 Self {
347 schema: "sdd.self-depend-sync/1",
348 target: target.clone(),
349 caller: match caller {
350 Caller::Envrc => "envrc",
351 Caller::Operator => "operator",
352 },
353 outcome,
354 manager: None,
355 from: None,
356 to: None,
357 moved: None,
358 message,
359 }
360 }
361}
362
363fn run_sync(ctx: &AppContext, args: SyncArgs) -> Result<(), AppError> {
376 let caller = args.caller;
377 let json = args.json;
378 match sync_inner(ctx, args) {
379 Ok(report) => {
380 if json {
381 output::json(&report)?;
382 } else if caller == Caller::Operator {
383 output::line(report.message);
384 }
385 Ok(())
386 }
387 Err(_) if caller == Caller::Envrc && !json => Ok(()),
389 Err(error) if caller == Caller::Envrc => output::json(&serde_json::json!({
390 "schema": "sdd.self-depend-sync/1",
391 "caller": "envrc",
392 "outcome": "failed",
393 "message": error.to_string(),
394 })),
395 Err(error) => Err(error),
396 }
397}
398
399fn choose_wired(
404 asked: Option<Manager>,
405 detected: &[Detected],
406 caller: Caller,
407) -> Result<Option<&Detected>, AppError> {
408 let wired = manager::wired(detected);
409 match (asked, wired.as_slice()) {
410 (Some(asked), held) => held
411 .iter()
412 .find(|held| held.manager == asked)
413 .copied()
414 .map(Some)
415 .ok_or_else(|| {
416 AppError::Usage(format!("{asked} does not pin this tool in this target"))
417 }),
418 (None, [one]) => Ok(Some(one)),
419 (None, []) if caller == Caller::Envrc => Ok(None),
420 (None, []) => Err(AppError::Usage(
421 "no manager names this tool in this target; sdd self-depend add serves the fragments"
422 .to_string(),
423 )),
424 (None, several) => {
425 let names: Vec<&str> = several.iter().map(|held| held.manager.as_str()).collect();
426 Err(AppError::Usage(format!(
427 "{} each pin this tool; name one with --manager",
428 names.join(" and ")
429 )))
430 }
431 }
432}
433
434fn sync_inner(ctx: &AppContext, args: SyncArgs) -> Result<SyncReport, AppError> {
435 let target = resolve_target(ctx, args.target)?;
436 let report =
437 |outcome: Outcome, message: String| SyncReport::new(&target, args.caller, outcome, message);
438 if status::switched_off() {
439 return Ok(report(
440 Outcome::Off,
441 format!("the loop is switched off: {CI_VAR} or {SELF_DEPEND_OFF_VAR} is set"),
442 ));
443 }
444 let detected = manager::detect(&target);
445 let Some(held) = choose_wired(args.manager, &detected, args.caller)? else {
446 return Ok(report(
447 Outcome::Unwired,
448 "no manager names this tool, so there is no pin to move".to_string(),
449 ));
450 };
451 let pin = held
452 .pin
453 .as_ref()
454 .ok_or_else(|| AppError::Refused(format!("{} names no pin", held.manager)))?;
455
456 let env = UserEnv::from_process();
458 if args.caller == Caller::Envrc
459 && args.tag.is_none()
460 && let Some(root) = env.state_root()
461 {
462 let path = stamp::path(&root.path, &target);
463 let today = stamp::today();
464 if stamp::attempted(&path, today) {
465 return Ok(report(
466 Outcome::Stamped,
467 format!("an attempt already ran today ({today}); at most one runs a day"),
468 ));
469 }
470 stamp::mark(&path, today)?;
471 }
472
473 let to = match args.tag.as_deref() {
474 Some(tag) => parse_tag(tag).map_err(AppError::Usage)?,
475 None => latest(&env)?,
476 };
477 let spelled_to = if pin.spelled.starts_with('v') {
478 format!("v{to}")
479 } else {
480 to.to_string()
481 };
482 let mut out = report(Outcome::Current, String::new());
483 out.manager = Some(held.manager);
484 out.from = Some(pin.spelled.clone());
485 out.to = Some(spelled_to.clone());
486 if pin.version == to {
487 out.message = format!("{} pins {} already", held.manager, pin.spelled);
488 return Ok(out);
489 }
490 if !args.apply {
491 out.outcome = Outcome::Pending;
492 out.message = format!(
493 "{} would move from {} to {spelled_to}; pass --apply",
494 held.manager, pin.spelled
495 );
496 return Ok(out);
497 }
498 let moved = txn::sync(&target, held, &to)?;
500 out.outcome = Outcome::Moved;
501 out.message = format!(
502 "{} moved from {} to {}; review and commit {}",
503 moved.manager,
504 moved.from,
505 moved.to,
506 moved
507 .files
508 .iter()
509 .map(|file| file.as_str())
510 .collect::<Vec<_>>()
511 .join(" and ")
512 );
513 out.moved = Some(moved);
514 Ok(out)
515}
516
517fn latest(env: &UserEnv) -> Result<Version, AppError> {
519 let cache = env
520 .user_paths()
521 .ok_or_else(|| AppError::Usage("no cache root resolves".to_string()))?
522 .bundle_cache
523 .path;
524 CratesIoResolver::new(&cache)
525 .latest_version()
526 .map_err(|error| anyhow::anyhow!("the latest release could not be read: {error}").into())
527}
528
529#[derive(Debug, Serialize)]
533struct CleanReport {
534 schema: &'static str,
535 target: Utf8PathBuf,
536 leftovers: Vec<Leftover>,
537 kept: Vec<Kept>,
538 removed: Vec<Utf8PathBuf>,
539 applied: bool,
540}
541
542fn run_clean(ctx: &AppContext, args: CleanArgs) -> Result<(), AppError> {
549 let target = resolve_target(ctx, args.target)?;
550 for also in &args.also {
551 if also.is_absolute()
552 || also
553 .components()
554 .any(|c| c == camino::Utf8Component::ParentDir)
555 {
556 return Err(AppError::Usage(format!(
557 "--also {also} must be relative and stay inside the target"
558 )));
559 }
560 }
561 let detected = manager::detect(&target);
562 let wired = manager::wired(&detected);
563 let found = leftovers::find(&target, &args.also);
564 let kept = leftovers::kept(&target, &wired);
565 let mut removed = Vec::new();
566 if args.apply {
567 for leftover in &found {
569 std::fs::remove_file(target.join(&leftover.file))?;
570 removed.push(leftover.file.clone());
571 }
572 }
573 let report = CleanReport {
574 schema: "sdd.self-depend-clean/1",
575 target,
576 leftovers: found,
577 kept,
578 removed,
579 applied: args.apply,
580 };
581 if args.json {
582 return output::json(&report);
583 }
584 if report.leftovers.is_empty() {
585 output::line("no leftover: this target runs one mechanism");
586 }
587 for leftover in &report.leftovers {
588 output::line(format!(
589 "{} {}: {}",
590 if args.apply { "removed" } else { "leftover" },
591 leftover.file,
592 leftover.reason
593 ));
594 }
595 for kept in &report.kept {
596 match kept.line.as_ref() {
597 Some(line) => output::line(format!("kept {} ({line}): {}", kept.file, kept.reason)),
598 None => output::line(format!("kept {}: {}", kept.file, kept.reason)),
599 }
600 }
601 Ok(())
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 fn ctx() -> AppContext {
609 AppContext {
610 cwd: Utf8PathBuf::from("/work"),
611 verbosity: 0,
612 }
613 }
614
615 #[test]
616 fn a_relative_target_is_a_usage_error() {
617 assert!(matches!(
618 resolve_target(&ctx(), Utf8PathBuf::from("relative")),
619 Err(AppError::Usage(_))
620 ));
621 assert_eq!(
622 resolve_target(&ctx(), Utf8PathBuf::from(".")).ok(),
623 Some(Utf8PathBuf::from("/work"))
624 );
625 assert_eq!(
626 resolve_target(&ctx(), Utf8PathBuf::from("/elsewhere")).ok(),
627 Some(Utf8PathBuf::from("/elsewhere"))
628 );
629 }
630
631 #[test]
633 fn a_second_manager_is_refused_where_one_already_pins_this_tool() {
634 let pinned = Detected {
635 manager: Manager::Mise,
636 file: Some(Utf8PathBuf::from("mise.toml")),
637 pin: crate::self_depend::pin::read(
638 Manager::Mise,
639 "[tools]\n\"cargo:spec-driven-docs\" = \"0.1.0\"\n",
640 ),
641 };
642 let detected = vec![pinned];
643 assert!(matches!(
644 choose_manager(Some(Manager::Flake), &detected),
645 Err(AppError::Refused(_))
646 ));
647 assert_eq!(choose_manager(None, &detected).ok(), Some(Manager::Mise));
648 assert_eq!(choose_manager(None, &[]).ok(), Some(Manager::Flake));
649 }
650}