Skip to main content

ready_set/builtins/
set.rs

1//! `ready-set set`: provider-backed setup and reconciliation.
2
3use std::ffi::OsString;
4
5use clap::Parser;
6use ready_set_sdk::{
7    CapabilityAction, CapabilityActionKind, CapabilityRelevance, CapabilityRunReport,
8    CapabilityVerb, ExitCode, OutputMode, RunStatus,
9};
10
11use crate::capabilities::{CapabilityRegistry, RegisteredCapability};
12use crate::env::EnvContract;
13use crate::lifecycle::{SetInvocation, invoke_set};
14
15/// Options accepted by `ready-set set`.
16#[derive(Debug, Clone, Parser)]
17#[command(name = "ready-set set", about, long_about = None, no_binary_name = true)]
18#[allow(clippy::struct_excessive_bools)]
19struct Options {
20    /// Capability id to reconcile.
21    pub capability: Option<String>,
22
23    /// Replace managed files even if their content has diverged.
24    #[arg(long)]
25    pub force: bool,
26
27    /// Plan and report writes without modifying any files.
28    #[arg(long)]
29    pub dry_run: bool,
30
31    /// Emit machine-readable JSON output.
32    #[arg(long)]
33    pub json: bool,
34
35    /// Errors only.
36    #[arg(long)]
37    pub quiet: bool,
38
39    /// Debug logging.
40    #[arg(long)]
41    pub verbose: bool,
42
43    /// Explicit member path to add to `[workspace.members]`. Repeatable.
44    #[arg(long = "member")]
45    pub members: Vec<String>,
46
47    /// Skip recursive crate discovery.
48    #[arg(long)]
49    pub no_discover: bool,
50}
51
52/// Built-in entry point. The dispatcher routes here for `ready-set set`.
53pub fn run(args: &[OsString], contract: &EnvContract) -> ExitCode {
54    let opts = match Options::try_parse_from(args) {
55        Ok(opts) => opts,
56        Err(err) => {
57            err.print().ok();
58            return ExitCode::UserError;
59        },
60    };
61    let cwd = match std::env::current_dir() {
62        Ok(cwd) => cwd,
63        Err(err) => {
64            eprintln!("ready-set set: cannot read current directory: {err}");
65            return ExitCode::SystemError;
66        },
67    };
68    let registry = match CapabilityRegistry::discover(&cwd) {
69        Ok(registry) => registry,
70        Err(err) => {
71            eprintln!("ready-set set: {err}");
72            return (&err).into();
73        },
74    };
75
76    let capabilities = match select_capabilities(&registry, opts.capability.as_deref()) {
77        Ok(capabilities) => capabilities,
78        Err(code) => return code,
79    };
80    if capabilities.is_empty() {
81        eprintln!("ready-set set: no required set-capable capabilities found");
82        return ExitCode::UserError;
83    }
84
85    let capture_json = matches!(contract.output, OutputMode::Json) || opts.json;
86    let provider_args = provider_args(&opts);
87    let mut reports = Vec::new();
88    let mut exit_code = ExitCode::Ok;
89
90    for capability in capabilities {
91        if capability.relevance == CapabilityRelevance::NotNeeded {
92            reports.push(noop_report(capability, "capability marked not needed"));
93            continue;
94        }
95
96        match invoke_set(
97            &capability.provider,
98            capability.id.as_str(),
99            &provider_args,
100            contract,
101            capture_json,
102        ) {
103            Ok(SetInvocation::Report(report)) => reports.push(report),
104            Ok(SetInvocation::Streamed { exit_code: code }) => {
105                if code != ExitCode::Ok {
106                    exit_code = code;
107                }
108            },
109            Ok(SetInvocation::ProviderUnavailable { summary }) => {
110                eprintln!("ready-set set: {summary}");
111                return ExitCode::UserError;
112            },
113            Ok(SetInvocation::ProviderFailed {
114                exit_code: code,
115                summary,
116            }) => {
117                eprintln!("ready-set set: {summary}");
118                return code;
119            },
120            Err(err) => {
121                eprintln!("ready-set set: {err}");
122                return ExitCode::SystemError;
123            },
124        }
125    }
126
127    if capture_json {
128        match serde_json::to_string(&reports) {
129            Ok(json) => println!("{json}"),
130            Err(err) => {
131                eprintln!("ready-set set: failed to serialize JSON report: {err}");
132                return ExitCode::SystemError;
133            },
134        }
135    }
136
137    exit_code
138}
139
140fn select_capabilities<'a>(
141    registry: &'a CapabilityRegistry,
142    selected: Option<&str>,
143) -> Result<Vec<&'a RegisteredCapability>, ExitCode> {
144    if let Some(id) = selected {
145        let Some(capability) = registry
146            .capabilities()
147            .iter()
148            .find(|capability| capability.id.as_str() == id)
149        else {
150            eprintln!("ready-set set: unknown capability `{id}`");
151            return Err(ExitCode::UserError);
152        };
153        if !capability.verbs.contains(&CapabilityVerb::Set) {
154            eprintln!("ready-set set: capability `{id}` does not support set");
155            return Err(ExitCode::UserError);
156        }
157        return Ok(vec![capability]);
158    }
159
160    Ok(registry
161        .capabilities()
162        .iter()
163        .filter(|capability| {
164            capability.relevance == CapabilityRelevance::Required
165                && capability.verbs.contains(&CapabilityVerb::Set)
166        })
167        .collect())
168}
169
170fn provider_args(opts: &Options) -> Vec<OsString> {
171    let mut args = Vec::new();
172    if opts.dry_run {
173        args.push(OsString::from("--dry-run"));
174    }
175    if opts.force {
176        args.push(OsString::from("--force"));
177    }
178    if opts.no_discover {
179        args.push(OsString::from("--no-discover"));
180    }
181    for member in &opts.members {
182        args.push(OsString::from("--member"));
183        args.push(OsString::from(member));
184    }
185    args
186}
187
188fn noop_report(capability: &RegisteredCapability, summary: &str) -> CapabilityRunReport {
189    CapabilityRunReport {
190        id: capability.id.clone(),
191        verb: CapabilityVerb::Set,
192        status: RunStatus::Noop,
193        actions: vec![CapabilityAction {
194            kind: CapabilityActionKind::Skip,
195            summary: summary.into(),
196            path: None,
197        }],
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn provider_args_preserve_setup_flags() {
207        let opts = Options {
208            capability: Some("workspace".into()),
209            force: true,
210            dry_run: true,
211            json: false,
212            quiet: false,
213            verbose: false,
214            members: vec!["crates/foo".into()],
215            no_discover: true,
216        };
217
218        assert_eq!(
219            provider_args(&opts),
220            vec![
221                OsString::from("--dry-run"),
222                OsString::from("--force"),
223                OsString::from("--no-discover"),
224                OsString::from("--member"),
225                OsString::from("crates/foo"),
226            ]
227        );
228    }
229
230    #[test]
231    fn no_capability_selects_required_set_capabilities_only() {
232        let manifest = ready_set_sdk::manifest::Manifest {
233            description: "test".into(),
234            version: "0.1.0".parse().unwrap(),
235            stability: ready_set_sdk::describe::Stability::Stable,
236            min_dispatcher_version: "0.1.0".parse().unwrap(),
237            platforms: vec![ready_set_sdk::describe::Platform::current().unwrap()],
238            requires_cargo_workspace: false,
239            capabilities: vec![
240                ready_set_sdk::CapabilityDescriptor {
241                    id: "required".into(),
242                    title: "Required".into(),
243                    provider: "provider".into(),
244                    verbs: vec![CapabilityVerb::Ready, CapabilityVerb::Set],
245                    default_relevance: CapabilityRelevance::Required,
246                },
247                ready_set_sdk::CapabilityDescriptor {
248                    id: "optional".into(),
249                    title: "Optional".into(),
250                    provider: "provider".into(),
251                    verbs: vec![CapabilityVerb::Ready, CapabilityVerb::Set],
252                    default_relevance: CapabilityRelevance::Optional,
253                },
254            ],
255        };
256        let registry = CapabilityRegistry::from_parts(None, [manifest]);
257        let selected = select_capabilities(&registry, None).unwrap();
258
259        assert_eq!(selected.len(), 1);
260        assert_eq!(selected[0].id.as_str(), "required");
261    }
262
263    #[test]
264    fn explicit_capability_must_support_set() {
265        let manifest = ready_set_sdk::manifest::Manifest {
266            description: "test".into(),
267            version: "0.1.0".parse().unwrap(),
268            stability: ready_set_sdk::describe::Stability::Stable,
269            min_dispatcher_version: "0.1.0".parse().unwrap(),
270            platforms: vec![ready_set_sdk::describe::Platform::current().unwrap()],
271            requires_cargo_workspace: false,
272            capabilities: vec![ready_set_sdk::CapabilityDescriptor {
273                id: "ready-only".into(),
274                title: "Ready only".into(),
275                provider: "provider".into(),
276                verbs: vec![CapabilityVerb::Ready],
277                default_relevance: CapabilityRelevance::Required,
278            }],
279        };
280        let registry = CapabilityRegistry::from_parts(None, [manifest]);
281
282        assert!(select_capabilities(&registry, Some("ready-only")).is_err());
283    }
284}