1use 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
31pub 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}