Skip to main content

spec_driven_docs/commands/
self_depend.rs

1//! `self-depend` subcommand: runtime-shape.
2//!
3//! Four verbs over one detection. `status` reports and never judges. `add`
4//! prints fragments and seeds only where the target has nothing. `sync`
5//! moves the pin in one transaction, under a stamp for the shell-entry
6//! caller. `clean` removes only what it names.
7
8use 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::self_depend::fragments::{self, Fragment};
22use crate::self_depend::leftovers::{self, Kept, Leftover};
23use crate::self_depend::manager::{self, Detected, Manager};
24use crate::self_depend::registry::Index;
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
30/// Dispatch one verb.
31///
32/// # Errors
33///
34/// Each verb's own, stated on its handler.
35pub 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
44/// The target, absolute or the working directory.
45fn 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
55/// The release this binary is.
56fn 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
69// --- status -----------------------------------------------------------------
70
71/// Report what a target carries, offline.
72///
73/// # Errors
74///
75/// [`AppError::Usage`] for a relative target. Every state reports and exits 0.
76fn 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// --- add --------------------------------------------------------------------
156
157/// What `add` served, and what it wrote.
158#[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
172/// The manager `add` serves: the one asked for, the one already naming
173/// this tool, the one manager file the target carries, or the flake.
174///
175/// SATISFIES acquisition:one-target-runs-one-mechanism
176fn 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
210/// Serve the fragments for one pair, and seed what the target lacks.
211///
212/// # Errors
213///
214/// [`AppError::Usage`] for a relative target, a tag that is not a release,
215/// or several managers with none named. [`AppError::Refused`] for a manager
216/// other than the one already naming this tool, and for a manual pair.
217fn 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    // SATISFIES acquisition:the-tool-serves-and-does-not-edit
244    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// --- sync -------------------------------------------------------------------
307
308/// What one sync run concluded.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
310#[serde(rename_all = "kebab-case")]
311enum Outcome {
312    /// The loop is switched off in this environment.
313    Off,
314    /// No manager names this tool, so there is no pin to move.
315    Unwired,
316    /// The stamp says an attempt already ran today.
317    Stamped,
318    /// The pin already names the release asked for.
319    Current,
320    /// The pin would move; `--apply` was not passed.
321    Pending,
322    /// The pin moved.
323    Moved,
324}
325
326/// The sync report.
327#[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
363/// Move the pin through the wired manager.
364///
365/// The shell-entry caller stays silent and exits 0 on every outcome. The
366/// operator caller reports every outcome and fails with the matrix below.
367///
368/// # Errors
369///
370/// For the operator caller: [`AppError::Usage`] for a relative target, a
371/// tag that is not a release, no wired manager, or several managers with
372/// none named; [`AppError::Other`] where the latest release cannot be read;
373/// [`AppError::Refused`] where the transaction failed and both files were
374/// put back.
375fn 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        // SATISFIES acquisition:the-shell-entry-caller-is-rate-limited-and-silent
388        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
399/// The one wired manager a sync moves, or why none can be chosen.
400///
401/// `Ok(None)` is the shell-entry caller's silent answer to a target that
402/// pins nothing.
403fn 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    // SATISFIES acquisition:the-shell-entry-caller-is-rate-limited-and-silent
457    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()?,
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    // SATISFIES acquisition:the-operator-caller-reports-every-outcome
499    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
517/// The latest stable release the registry serves.
518fn latest() -> Result<Version, AppError> {
519    Index::new()
520        .latest_version()
521        .map_err(|error| anyhow::anyhow!("the latest release could not be read: {error}").into())
522}
523
524// --- clean ------------------------------------------------------------------
525
526/// The clean report.
527#[derive(Debug, Serialize)]
528struct CleanReport {
529    schema: &'static str,
530    target: Utf8PathBuf,
531    leftovers: Vec<Leftover>,
532    kept: Vec<Kept>,
533    removed: Vec<Utf8PathBuf>,
534    applied: bool,
535}
536
537/// Remove what a predecessor left, and name what stays.
538///
539/// # Errors
540///
541/// [`AppError::Usage`] for a relative target or an `--also` path that
542/// leaves it; [`AppError::Io`] where a removal fails.
543fn run_clean(ctx: &AppContext, args: CleanArgs) -> Result<(), AppError> {
544    let target = resolve_target(ctx, args.target)?;
545    for also in &args.also {
546        if also.is_absolute()
547            || also
548                .components()
549                .any(|c| c == camino::Utf8Component::ParentDir)
550        {
551            return Err(AppError::Usage(format!(
552                "--also {also} must be relative and stay inside the target"
553            )));
554        }
555    }
556    let detected = manager::detect(&target);
557    let wired = manager::wired(&detected);
558    let found = leftovers::find(&target, &args.also);
559    let kept = leftovers::kept(&target, &wired);
560    let mut removed = Vec::new();
561    if args.apply {
562        // SATISFIES acquisition:clean-removes-only-a-named-leftover
563        for leftover in &found {
564            std::fs::remove_file(target.join(&leftover.file))?;
565            removed.push(leftover.file.clone());
566        }
567    }
568    let report = CleanReport {
569        schema: "sdd.self-depend-clean/1",
570        target,
571        leftovers: found,
572        kept,
573        removed,
574        applied: args.apply,
575    };
576    if args.json {
577        return output::json(&report);
578    }
579    if report.leftovers.is_empty() {
580        output::line("no leftover: this target runs one mechanism");
581    }
582    for leftover in &report.leftovers {
583        output::line(format!(
584            "{} {}: {}",
585            if args.apply { "removed" } else { "leftover" },
586            leftover.file,
587            leftover.reason
588        ));
589    }
590    for kept in &report.kept {
591        match kept.line.as_ref() {
592            Some(line) => output::line(format!("kept {} ({line}): {}", kept.file, kept.reason)),
593            None => output::line(format!("kept {}: {}", kept.file, kept.reason)),
594        }
595    }
596    Ok(())
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    fn ctx() -> AppContext {
604        AppContext {
605            cwd: Utf8PathBuf::from("/work"),
606            verbosity: 0,
607        }
608    }
609
610    #[test]
611    fn a_relative_target_is_a_usage_error() {
612        assert!(matches!(
613            resolve_target(&ctx(), Utf8PathBuf::from("relative")),
614            Err(AppError::Usage(_))
615        ));
616        assert_eq!(
617            resolve_target(&ctx(), Utf8PathBuf::from(".")).ok(),
618            Some(Utf8PathBuf::from("/work"))
619        );
620        assert_eq!(
621            resolve_target(&ctx(), Utf8PathBuf::from("/elsewhere")).ok(),
622            Some(Utf8PathBuf::from("/elsewhere"))
623        );
624    }
625
626    /// VERIFIES acquisition:one-target-runs-one-mechanism
627    #[test]
628    fn a_second_manager_is_refused_where_one_already_pins_this_tool() {
629        let pinned = Detected {
630            manager: Manager::Mise,
631            file: Some(Utf8PathBuf::from("mise.toml")),
632            pin: crate::self_depend::pin::read(
633                Manager::Mise,
634                "[tools]\n\"cargo:spec-driven-docs\" = \"0.1.0\"\n",
635            ),
636        };
637        let detected = vec![pinned];
638        assert!(matches!(
639            choose_manager(Some(Manager::Flake), &detected),
640            Err(AppError::Refused(_))
641        ));
642        assert_eq!(choose_manager(None, &detected).ok(), Some(Manager::Mise));
643        assert_eq!(choose_manager(None, &[]).ok(), Some(Manager::Flake));
644    }
645}