spec_driven_docs/commands/
reconcile.rs1use camino::{Utf8Path, Utf8PathBuf};
9
10use crate::cli::reconcile::{ApplyArgs, PlanArgs, ReconcileArgs, ReconcileCommand, ShowArgs};
11use crate::context::AppContext;
12use crate::error::AppError;
13use crate::output;
14use crate::plan::Plan;
15use crate::plan::apply::{Request, apply as execute};
16use crate::plan::decision;
17use crate::plan::readiness::Readiness;
18use crate::plan::session::{
19 STORE_WAIT, blobs_for, compute_plan, read_release, state_root, target_lock,
20};
21use crate::plan::store::{Result as ApplyResult, Store};
22use crate::transaction::lock::Lock;
23
24pub fn run(ctx: &AppContext, args: ReconcileArgs) -> Result<(), AppError> {
32 match args.command {
33 ReconcileCommand::Plan(plan) => run_plan(ctx, &plan),
34 ReconcileCommand::Show(show) => run_show(&show),
35 ReconcileCommand::Apply(apply) => run_apply(ctx, &apply),
36 }
37}
38
39fn resolve_target(ctx: &AppContext, target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
40 let named = if target.is_absolute() {
41 target.to_owned()
42 } else if target == "." {
43 ctx.cwd.clone()
44 } else {
45 return Err(AppError::Usage("target must be absolute or .".to_string()));
46 };
47 let resolved = std::fs::canonicalize(&named)
51 .map_err(|_| AppError::Usage(format!("unresolved target: {named}")))?;
52 Utf8PathBuf::from_path_buf(resolved)
53 .map_err(|path| AppError::Usage(format!("target is not UTF-8: {}", path.display())))
54}
55
56fn run_plan(ctx: &AppContext, args: &PlanArgs) -> Result<(), AppError> {
57 let target = resolve_target(ctx, &args.target)?;
58 let selections =
59 decision::parse(&args.set).map_err(|error| AppError::Usage(error.to_string()))?;
60 let release = read_release(&args.to, args.offline)?;
61
62 let _lock = Lock::shared(&target_lock(&target)?, "reconcile plan")?;
65 let (computed, blobs) = compute_plan(
66 &target,
67 &args.to,
68 args.offline,
69 &selections,
70 &args.reserve,
71 None,
72 &release.borrow(),
73 )?;
74 decision::validate(&computed.decisions, &selections)
75 .map_err(|error| AppError::Usage(error.to_string()))?;
76
77 let store = Store::new(&state_root()?);
78 store.create()?;
81 if let Ok(_walk) = Lock::exclusive(&store.lock_path(), "plan store prune") {
82 let _ = store.prune(jiff::Timestamp::now(), Some(&computed.identity.plan_id));
83 }
84 let _plan_lock = Lock::exclusive_waiting(
85 &store.plan_lock_path(&computed.identity.plan_id)?,
86 "reconcile plan",
87 STORE_WAIT,
88 )?;
89 if !store.holds(&computed.identity.plan_id) {
92 let mut blobs = blobs;
95 for (digest, bytes) in blobs_for(&computed, release.bundle.as_ref()) {
96 blobs.entry(digest).or_insert(bytes);
97 }
98 store.put(&computed, &blobs)?;
99 }
100
101 if args.json {
102 return output::json(&computed);
103 }
104 render(&computed);
105 output::line(format!(
106 "apply it with: sdd reconcile apply {}",
107 computed.identity.plan_id
108 ));
109 Ok(())
110}
111
112fn run_show(args: &ShowArgs) -> Result<(), AppError> {
113 let store = Store::new(&state_root()?);
114 if let Ok(plan) = store.get(&args.plan_id) {
115 if args.json {
116 return output::json(&plan);
117 }
118 output::line("executable plan");
119 render(&plan);
120 return Ok(());
121 }
122 let result = store.latest_result(&args.plan_id).ok_or_else(|| {
123 AppError::Refused(format!(
124 "no plan and no result carry the id {}; run 'sdd reconcile plan' again",
125 args.plan_id
126 ))
127 })?;
128 if args.json {
129 return output::json(&result);
130 }
131 output::line("latest result");
132 output::line(format!("disposition: {:?}", result.disposition));
133 output::line(format!("reason: {}", result.reason));
134 Ok(())
135}
136
137fn run_apply(ctx: &AppContext, args: &ApplyArgs) -> Result<(), AppError> {
138 let target = resolve_target(ctx, &args.target)?;
139 let store = Store::new(&state_root()?);
140 let _plan_lock = Lock::exclusive_waiting(
144 &store.plan_lock_path(&args.plan_id)?,
145 "reconcile apply",
146 STORE_WAIT,
147 )?;
148 let stored = store.get(&args.plan_id)?;
149
150 let _lock = Lock::exclusive(&target_lock(&target)?, "reconcile apply")?;
153 let selections: decision::Selections = stored
154 .decisions
155 .iter()
156 .filter_map(|decision| {
157 decision
158 .selected
159 .as_ref()
160 .map(|answer| (decision.id.clone(), answer.clone()))
161 })
162 .collect();
163 let frozen = if stored.desired_state.selector == "embedded" {
169 "embedded".to_string()
173 } else {
174 stored.desired_state.release.clone()
175 };
176 let release = read_release(&frozen, true)?;
177 let (recomputed, _) = compute_plan(
178 &target,
179 &stored.desired_state.selector,
180 true,
181 &selections,
182 &stored.desired_state.reserved,
183 None,
184 &release.borrow(),
185 )?;
186
187 let result = execute(&Request {
190 store: &store,
191 target: &target,
192 stored: &stored,
193 recomputed: &recomputed,
194 bundle: release.bundle.as_ref(),
195 now: jiff::Timestamp::now().to_string(),
196 })?;
197 if args.json {
198 return output::json(&result);
199 }
200 render_result(&result);
201 Ok(())
202}
203
204fn render_result(result: &ApplyResult) {
205 output::line(format!("{:?}: {}", result.disposition, result.reason));
206 for operation in &result.operations {
207 output::line(format!(" {} {}", operation.kind, operation.path));
208 }
209 for postcondition in &result.postconditions {
210 let word = if postcondition.held { "held" } else { "FAILED" };
211 output::line(format!(" {word}: {}", postcondition.id));
212 }
213}
214
215fn render(plan: &Plan) {
216 output::line(format!(
217 "{} toward {} ({})",
218 plan.classification, plan.desired_state.release, plan.desired_state.selector
219 ));
220 output::line(format!("plan {}", plan.identity.plan_id));
221 let word = match plan.readiness {
222 Readiness::Ready => "ready",
223 Readiness::NeedsDecision => "needs a decision",
224 Readiness::Blocked => "blocked",
225 };
226 output::line(format!("readiness: {word}"));
227 if !plan.findings.is_empty() {
228 output::line(format!("findings: {}", plan.findings.len()));
229 for found in &plan.findings {
230 output::line(format!(
231 " {} {}: {}",
232 found.rule, found.path, found.statement
233 ));
234 }
235 }
236 if !plan.style_candidates.is_empty() {
237 output::line(format!(
238 "style candidates: {} (named, never judged)",
239 plan.style_candidates.len()
240 ));
241 }
242 output::line(format!("operations: {}", plan.operations.len()));
243 for operation in &plan.operations {
244 output::line(format!(" {} {}", operation.kind(), operation.path()));
245 }
246 for precondition in &plan.preconditions {
247 if precondition.evaluation.is_satisfied() {
248 continue;
249 }
250 output::line(format!(
251 " blocked by {}: {}",
252 precondition.id, precondition.statement
253 ));
254 }
255 for decision in &plan.decisions {
256 if decision.selected.is_some() {
257 continue;
258 }
259 output::line(format!(" decide --set {}=<answer>", decision.id));
260 }
261}