Skip to main content

ready_set_rust/
runner.rs

1//! Setup/reconciliation execution for Rust capabilities.
2
3use std::path::{Path, PathBuf};
4
5use ready_set_sdk::change_log::{ChangeLog, ChangeOp, ChangeRecord, backup_file};
6use ready_set_sdk::fs::{atomic_write, sha256_bytes, sha256_file};
7use ready_set_sdk::{
8    CapabilityAction, CapabilityActionKind, CapabilityRunReport, CapabilityVerb, Context, Error,
9    Output, OutputMode, Result, RunStatus,
10};
11use time::OffsetDateTime;
12use toml_edit::DocumentMut;
13
14use crate::PROVIDER_ID;
15use crate::gitignore;
16use crate::manifest_edit;
17use crate::members;
18use crate::options::SetOptions;
19use crate::ready_set_toml;
20use crate::templates::{CLIPPY, RUST_TOOLCHAIN, RUSTFMT};
21use crate::workspace::Workspace;
22
23struct PlannedWrite {
24    abs: PathBuf,
25    content: String,
26    rel: String,
27    op: ChangeOp,
28    before_content: Option<String>,
29}
30
31/// Execute `set` for one Rust capability.
32///
33/// # Errors
34///
35/// Forwards SDK errors from I/O, TOML parsing, or change-log writes.
36pub fn set_capability(
37    capability: &str,
38    workspace: &Workspace,
39    opts: &SetOptions,
40    ctx: &Context,
41) -> Result<CapabilityRunReport> {
42    let mut actions = Vec::new();
43    let mut planned_writes = Vec::new();
44
45    match capability {
46        "workspace" => plan_workspace(workspace, opts, &mut actions, &mut planned_writes)?,
47        "toolchain" => plan_template_file(
48            &workspace.root,
49            "rust-toolchain.toml",
50            RUST_TOOLCHAIN,
51            opts.force,
52            &mut actions,
53            &mut planned_writes,
54        ),
55        "formatting" => plan_template_file(
56            &workspace.root,
57            "rustfmt.toml",
58            RUSTFMT,
59            opts.force,
60            &mut actions,
61            &mut planned_writes,
62        ),
63        "linting" => plan_linting(workspace, opts, &mut actions, &mut planned_writes)?,
64        other => {
65            return Err(Error::contract(format!(
66                "unknown Rust capability `{other}`"
67            )));
68        },
69    }
70
71    execute_planned_writes(workspace, opts, &mut actions, &planned_writes)?;
72
73    let status = if planned_writes.is_empty() || opts.dry_run {
74        RunStatus::Noop
75    } else {
76        RunStatus::Changed
77    };
78    let report = CapabilityRunReport {
79        id: capability.into(),
80        verb: CapabilityVerb::Set,
81        status,
82        actions,
83    };
84    render_report(capability, &report, opts, ctx)?;
85    Ok(report)
86}
87
88fn plan_workspace(
89    workspace: &Workspace,
90    opts: &SetOptions,
91    actions: &mut Vec<CapabilityAction>,
92    planned_writes: &mut Vec<PlannedWrite>,
93) -> Result<()> {
94    let gi_path = workspace.root.join(".gitignore");
95    let gi_current = std::fs::read_to_string(&gi_path).ok();
96    if let Some(new_content) = gitignore::plan(gi_current.as_deref()) {
97        let op = if gi_current.is_some() {
98            ChangeOp::Modify
99        } else {
100            ChangeOp::Create
101        };
102        planned_writes.push(PlannedWrite {
103            abs: gi_path,
104            content: new_content,
105            rel: ".gitignore".into(),
106            op,
107            before_content: gi_current,
108        });
109    } else {
110        actions.push(check(".gitignore", "managed block already up to date"));
111    }
112
113    let config_path = workspace.root.join(".ready-set.toml");
114    if config_path.is_file() {
115        actions.push(check(".ready-set.toml", "already exists"));
116    } else {
117        planned_writes.push(PlannedWrite {
118            abs: config_path,
119            content: ready_set_toml::render(
120                workspace.has_workspace_table || workspace.has_package_table,
121            ),
122            rel: ".ready-set.toml".into(),
123            op: ChangeOp::Create,
124            before_content: None,
125        });
126    }
127
128    let manifest_raw = std::fs::read_to_string(&workspace.manifest_path)?;
129    let mut doc: DocumentMut = manifest_raw.parse().map_err(|e: toml_edit::TomlError| {
130        Error::TomlParse(format!("{}: {e}", workspace.manifest_path.display()))
131    })?;
132    let mut desired_members = Vec::new();
133    if !opts.no_discover {
134        desired_members.extend(members::discover(&workspace.root));
135    }
136    desired_members.extend(opts.members.clone());
137    let plan = manifest_edit::apply_workspace(&mut doc, &desired_members)?;
138    if plan.changed {
139        planned_writes.push(PlannedWrite {
140            abs: workspace.manifest_path.clone(),
141            content: doc.to_string(),
142            rel: "Cargo.toml".into(),
143            op: ChangeOp::Modify,
144            before_content: Some(manifest_raw),
145        });
146    } else {
147        actions.push(check("Cargo.toml", "workspace already up to date"));
148    }
149    for member in plan.added_members {
150        actions.push(CapabilityAction {
151            kind: CapabilityActionKind::Check,
152            summary: format!("workspace member planned: {member}"),
153            path: Some("Cargo.toml".into()),
154        });
155    }
156
157    Ok(())
158}
159
160fn plan_linting(
161    workspace: &Workspace,
162    opts: &SetOptions,
163    actions: &mut Vec<CapabilityAction>,
164    planned_writes: &mut Vec<PlannedWrite>,
165) -> Result<()> {
166    plan_template_file(
167        &workspace.root,
168        "clippy.toml",
169        CLIPPY,
170        opts.force,
171        actions,
172        planned_writes,
173    );
174
175    let manifest_raw = std::fs::read_to_string(&workspace.manifest_path)?;
176    let mut doc: DocumentMut = manifest_raw.parse().map_err(|e: toml_edit::TomlError| {
177        Error::TomlParse(format!("{}: {e}", workspace.manifest_path.display()))
178    })?;
179    let plan = manifest_edit::apply_lints(&mut doc, opts.force)?;
180    if plan.changed {
181        planned_writes.push(PlannedWrite {
182            abs: workspace.manifest_path.clone(),
183            content: doc.to_string(),
184            rel: "Cargo.toml".into(),
185            op: ChangeOp::Modify,
186            before_content: Some(manifest_raw),
187        });
188    } else if plan.lints_drifted {
189        actions.push(skip(
190            "Cargo.toml",
191            "workspace lints differ from template; pass --force to overwrite",
192        ));
193    } else {
194        actions.push(check("Cargo.toml", "workspace lints already up to date"));
195    }
196
197    Ok(())
198}
199
200fn plan_template_file(
201    root: &Path,
202    rel: &'static str,
203    template: &str,
204    force: bool,
205    actions: &mut Vec<CapabilityAction>,
206    planned_writes: &mut Vec<PlannedWrite>,
207) {
208    let abs = root.join(rel);
209    let current = std::fs::read_to_string(&abs).ok();
210    if let Some(current) = current {
211        if current == template {
212            actions.push(check(rel, "already up to date"));
213        } else if force {
214            planned_writes.push(PlannedWrite {
215                abs,
216                content: template.into(),
217                rel: rel.into(),
218                op: ChangeOp::Modify,
219                before_content: Some(current),
220            });
221        } else {
222            actions.push(skip(
223                rel,
224                "differs from template; pass --force to overwrite",
225            ));
226        }
227    } else {
228        planned_writes.push(PlannedWrite {
229            abs,
230            content: template.into(),
231            rel: rel.into(),
232            op: ChangeOp::Create,
233            before_content: None,
234        });
235    }
236}
237
238fn execute_planned_writes(
239    workspace: &Workspace,
240    opts: &SetOptions,
241    actions: &mut Vec<CapabilityAction>,
242    planned_writes: &[PlannedWrite],
243) -> Result<()> {
244    let mut log = if opts.dry_run || planned_writes.is_empty() {
245        None
246    } else {
247        Some(ChangeLog::open(&workspace.root, PROVIDER_ID)?)
248    };
249
250    for write in planned_writes {
251        if opts.dry_run {
252            actions.push(skip(&write.rel, "plan only"));
253            continue;
254        }
255
256        let before_sha = write
257            .before_content
258            .as_ref()
259            .map(|prev| sha256_bytes(prev.as_bytes()));
260        if matches!(write.op, ChangeOp::Modify) && write.abs.is_file() {
261            backup_file(&workspace.root, &write.abs)?;
262        }
263        atomic_write(&write.abs, write.content.as_bytes())?;
264        let after_sha = sha256_file(&write.abs)?;
265        if let Some(log) = log.as_mut() {
266            log.record(&ChangeRecord {
267                op: write.op,
268                path: relative_path(&workspace.root, &write.abs),
269                before_sha256: before_sha,
270                after_sha256: Some(after_sha),
271                ts: OffsetDateTime::now_utc(),
272            })?;
273        }
274        actions.push(CapabilityAction {
275            kind: match write.op {
276                ChangeOp::Create => CapabilityActionKind::Create,
277                ChangeOp::Modify => CapabilityActionKind::Modify,
278                ChangeOp::Delete => CapabilityActionKind::Delete,
279            },
280            summary: "written".into(),
281            path: Some(write.rel.clone()),
282        });
283    }
284
285    Ok(())
286}
287
288fn render_report(
289    capability: &str,
290    report: &CapabilityRunReport,
291    opts: &SetOptions,
292    ctx: &Context,
293) -> Result<()> {
294    let mut out = Output::for_context(ctx, std::io::stdout());
295    if matches!(ctx.output_mode(), OutputMode::Json) {
296        out.json(report)?;
297        return Ok(());
298    }
299
300    if opts.dry_run {
301        out.human(&format!("ready-set-rust set {capability} (dry-run)"));
302    } else {
303        out.human(&format!("ready-set-rust set {capability}"));
304    }
305    for action in &report.actions {
306        let path = action.path.as_deref().unwrap_or("-");
307        out.human(&format!(
308            "  {:<8} {:<24} {}",
309            action_kind_label(action.kind),
310            path,
311            action.summary
312        ));
313    }
314    Ok(())
315}
316
317fn check(path: impl Into<String>, summary: impl Into<String>) -> CapabilityAction {
318    CapabilityAction {
319        kind: CapabilityActionKind::Check,
320        summary: summary.into(),
321        path: Some(path.into()),
322    }
323}
324
325fn skip(path: impl Into<String>, summary: impl Into<String>) -> CapabilityAction {
326    CapabilityAction {
327        kind: CapabilityActionKind::Skip,
328        summary: summary.into(),
329        path: Some(path.into()),
330    }
331}
332
333const fn action_kind_label(kind: CapabilityActionKind) -> &'static str {
334    match kind {
335        CapabilityActionKind::Create => "create",
336        CapabilityActionKind::Modify => "modify",
337        CapabilityActionKind::Delete => "delete",
338        CapabilityActionKind::Run => "run",
339        CapabilityActionKind::Check => "check",
340        CapabilityActionKind::Skip => "skip",
341        CapabilityActionKind::Error => "error",
342    }
343}
344
345fn relative_path(root: &Path, abs: &Path) -> PathBuf {
346    abs.strip_prefix(root).map_or_else(
347        |_| abs.to_path_buf(),
348        |path| PathBuf::from(path.to_string_lossy().replace('\\', "/")),
349    )
350}