1use crate::Result;
4use crate::strategy::artifacts::read_receipt_artifact;
5use crate::strategy::{
6 LaneClaim, LaneId, LanePassAssignment, LanePassConfig, LanePassStore, OutcomeProof, PassNumber,
7 ProofReceipt, ReceiptFormat, WorkerId, WorktreeIsolationPlan, WorktreeMetadata,
8 detect_worktree_metadata,
9};
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub struct StrategyCommand;
15
16#[derive(Debug, Clone, Default)]
17pub struct StrategyStateOptions {
18 pub cycle_lanes: bool,
19 pub project_root: Option<PathBuf>,
20 pub strict_isolation: bool,
21 pub handoff_required_for_next: bool,
22}
23
24impl StrategyStateOptions {
25 pub fn with_lane_cycling(mut self, cycle_lanes: bool) -> Self {
26 self.cycle_lanes = cycle_lanes;
27 self
28 }
29
30 pub fn with_project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
31 self.project_root = Some(project_root.into());
32 self
33 }
34
35 pub fn with_strict_isolation(mut self, strict_isolation: bool) -> Self {
36 self.strict_isolation = strict_isolation;
37 self
38 }
39
40 pub fn with_handoff_required_for_next(mut self, required: bool) -> Self {
41 self.handoff_required_for_next = required;
42 self
43 }
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct StrategyClaimOutput {
48 pub claim: LaneClaim,
49 pub receipt_id: String,
50 pub handoff: crate::strategy::NextPassHandoff,
51 pub worktree: WorktreeMetadata,
52 pub worktree_plan: WorktreeIsolationPlan,
53 pub recommended_small_commands: Vec<String>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct StrategyWorktreeInspection {
58 pub worktree: WorktreeMetadata,
59 pub worktree_plan: WorktreeIsolationPlan,
60}
61
62impl StrategyCommand {
63 pub fn inspect_worktree(path: &Path, json: bool) -> Result<String> {
64 let metadata = detect_worktree_metadata(path);
65 let plan = WorktreeIsolationPlan::from_metadata(metadata.clone());
66 let inspection = StrategyWorktreeInspection {
67 worktree: metadata,
68 worktree_plan: plan,
69 };
70 if json {
71 return serde_json::to_string_pretty(&inspection)
72 .map(|mut output| {
73 output.push('\n');
74 output
75 })
76 .map_err(|e| crate::DrivenError::Format(format!("failed to render JSON: {}", e)));
77 }
78
79 Ok(format!(
80 "Worktree kind: {:?}\nRoot: {}\nBranch: {}\nDirty: {}\nDecision: {:?}\n",
81 inspection.worktree.kind,
82 inspection
83 .worktree
84 .worktree_root
85 .as_ref()
86 .map(|path| path.display().to_string())
87 .unwrap_or_else(|| "not a repository".to_string()),
88 inspection
89 .worktree
90 .branch
91 .as_deref()
92 .unwrap_or("detached-or-none"),
93 inspection
94 .worktree
95 .is_dirty
96 .map(|dirty| dirty.to_string())
97 .unwrap_or_else(|| "unknown".to_string()),
98 inspection.worktree_plan.creation_decision
99 ))
100 }
101
102 pub fn claim(
103 project_root: &Path,
104 lane: u8,
105 pass: u32,
106 worker_id: &str,
107 scope: &str,
108 next_action: &str,
109 json: bool,
110 ) -> Result<String> {
111 let worker = WorkerId::new(worker_id)?;
112 let claim = LaneClaim::new(LaneId::new(lane)?, PassNumber::new(pass)?, worker, scope);
113 let receipt = ProofReceipt::new(claim.clone(), "lane/pass claim modeled").with_outcome(
114 OutcomeProof::partial(
115 "claim created; run verification commands before closing the pass",
116 ),
117 );
118 let receipt_id = receipt.receipt_id()?;
119 let handoff = claim.next_pass_handoff(receipt.clone(), next_action)?;
120
121 let worktree = detect_worktree_metadata(project_root);
122 let worktree_plan = WorktreeIsolationPlan::from_metadata(worktree.clone());
123 let output = StrategyClaimOutput {
124 claim,
125 receipt_id,
126 handoff,
127 worktree,
128 worktree_plan,
129 recommended_small_commands: vec![
130 "git status --short --branch".to_string(),
131 "rg -n \"(<{7}|>{7}|={7})\"".to_string(),
132 "cargo fmt --check".to_string(),
133 ],
134 };
135
136 if json {
137 serde_json::to_string_pretty(&output)
138 .map(|mut output| {
139 output.push('\n');
140 output
141 })
142 .map_err(|e| crate::DrivenError::Format(format!("failed to render JSON: {}", e)))
143 } else {
144 render_claim_markdown(&output, &receipt)
145 }
146 }
147
148 pub fn peek_state(
149 state_dir: PathBuf,
150 scope: &str,
151 max_lanes: u8,
152 max_passes: u32,
153 json: bool,
154 ) -> Result<String> {
155 Self::peek_state_with_options(
156 state_dir,
157 scope,
158 max_lanes,
159 max_passes,
160 json,
161 StrategyStateOptions::default(),
162 )
163 }
164
165 pub fn peek_state_with_options(
166 state_dir: PathBuf,
167 scope: &str,
168 max_lanes: u8,
169 max_passes: u32,
170 json: bool,
171 options: StrategyStateOptions,
172 ) -> Result<String> {
173 let store = LanePassStore::new(
174 state_config(state_dir, scope, &options)
175 .with_max_lanes(max_lanes)
176 .with_max_passes(max_passes),
177 )?;
178 render_assignment(&store.peek_next_claim()?, json)
179 }
180
181 pub fn claim_state(
182 state_dir: PathBuf,
183 scope: &str,
184 max_lanes: u8,
185 max_passes: u32,
186 worker_id: &str,
187 json: bool,
188 ) -> Result<String> {
189 Self::claim_state_with_options(
190 state_dir,
191 scope,
192 max_lanes,
193 max_passes,
194 worker_id,
195 json,
196 StrategyStateOptions::default(),
197 )
198 }
199
200 pub fn claim_state_with_options(
201 state_dir: PathBuf,
202 scope: &str,
203 max_lanes: u8,
204 max_passes: u32,
205 worker_id: &str,
206 json: bool,
207 options: StrategyStateOptions,
208 ) -> Result<String> {
209 let config = state_config(state_dir, scope, &options)
210 .with_max_lanes(max_lanes)
211 .with_max_passes(max_passes);
212 enforce_strict_isolation(&config, &options)?;
213 let store = LanePassStore::new(config)?;
214 render_assignment(&store.claim(WorkerId::new(worker_id)?)?, json)
215 }
216
217 pub fn next_state(
218 state_dir: PathBuf,
219 scope: &str,
220 max_lanes: u8,
221 max_passes: u32,
222 worker_id: &str,
223 json: bool,
224 ) -> Result<String> {
225 Self::next_state_with_options(
226 state_dir,
227 scope,
228 max_lanes,
229 max_passes,
230 worker_id,
231 json,
232 StrategyStateOptions::default(),
233 )
234 }
235
236 pub fn next_state_with_options(
237 state_dir: PathBuf,
238 scope: &str,
239 max_lanes: u8,
240 max_passes: u32,
241 worker_id: &str,
242 json: bool,
243 options: StrategyStateOptions,
244 ) -> Result<String> {
245 let config = state_config(state_dir, scope, &options)
246 .with_max_lanes(max_lanes)
247 .with_max_passes(max_passes);
248 enforce_strict_isolation(&config, &options)?;
249 let store = LanePassStore::new(config)?;
250 render_assignment(&store.next_pass(&WorkerId::new(worker_id)?)?, json)
251 }
252
253 pub fn next_state_with_handoff_options(
254 state_dir: PathBuf,
255 scope: &str,
256 max_lanes: u8,
257 max_passes: u32,
258 worker_id: &str,
259 receipt_path: &Path,
260 next_action: &str,
261 json: bool,
262 options: StrategyStateOptions,
263 ) -> Result<String> {
264 let config = state_config(state_dir, scope, &options)
265 .with_max_lanes(max_lanes)
266 .with_max_passes(max_passes);
267 enforce_strict_isolation(&config, &options)?;
268 let store = LanePassStore::new(config)?;
269 let worker = WorkerId::new(worker_id)?;
270 let receipt = read_receipt_artifact(receipt_path)?;
271 render_assignment(
272 &store.next_pass_with_handoff(&worker, &receipt, next_action)?,
273 json,
274 )
275 }
276
277 pub fn release_state(
278 state_dir: PathBuf,
279 scope: &str,
280 max_lanes: u8,
281 max_passes: u32,
282 worker_id: &str,
283 json: bool,
284 ) -> Result<String> {
285 Self::release_state_with_options(
286 state_dir,
287 scope,
288 max_lanes,
289 max_passes,
290 worker_id,
291 json,
292 StrategyStateOptions::default(),
293 )
294 }
295
296 pub fn release_state_with_options(
297 state_dir: PathBuf,
298 scope: &str,
299 max_lanes: u8,
300 max_passes: u32,
301 worker_id: &str,
302 json: bool,
303 options: StrategyStateOptions,
304 ) -> Result<String> {
305 let config = state_config(state_dir, scope, &options)
306 .with_max_lanes(max_lanes)
307 .with_max_passes(max_passes);
308 enforce_strict_isolation(&config, &options)?;
309 let store = LanePassStore::new(config)?;
310 render_assignment(&store.release_lane(&WorkerId::new(worker_id)?)?, json)
311 }
312
313 pub fn complete_state(
314 _state_dir: PathBuf,
315 _scope: &str,
316 _max_lanes: u8,
317 _max_passes: u32,
318 _worker_id: &str,
319 _json: bool,
320 ) -> Result<String> {
321 Err(crate::DrivenError::Validation(
322 "completion requires a canonical proof receipt; use complete_state_with_receipt"
323 .to_string(),
324 ))
325 }
326
327 pub fn complete_state_with_receipt(
328 state_dir: PathBuf,
329 scope: &str,
330 max_lanes: u8,
331 max_passes: u32,
332 worker_id: &str,
333 receipt_path: &Path,
334 json: bool,
335 ) -> Result<String> {
336 Self::complete_state_with_receipt_options(
337 state_dir,
338 scope,
339 max_lanes,
340 max_passes,
341 worker_id,
342 receipt_path,
343 json,
344 StrategyStateOptions::default(),
345 )
346 }
347
348 pub fn complete_state_with_receipt_options(
349 state_dir: PathBuf,
350 scope: &str,
351 max_lanes: u8,
352 max_passes: u32,
353 worker_id: &str,
354 receipt_path: &Path,
355 json: bool,
356 options: StrategyStateOptions,
357 ) -> Result<String> {
358 let config = state_config(state_dir, scope, &options)
359 .with_max_lanes(max_lanes)
360 .with_max_passes(max_passes);
361 enforce_strict_isolation(&config, &options)?;
362 let store = LanePassStore::new(config)?;
363 let worker = WorkerId::new(worker_id)?;
364 let receipt = read_receipt_artifact(receipt_path)?;
365 render_assignment(&store.complete_pass_with_receipt(&worker, &receipt)?, json)
366 }
367}
368
369pub(super) fn render_receipt(receipt: &ProofReceipt, json: bool) -> Result<String> {
370 if json {
371 return receipt.render(ReceiptFormat::Json);
372 }
373
374 receipt.render(ReceiptFormat::Markdown)
375}
376
377fn render_claim_markdown(output: &StrategyClaimOutput, receipt: &ProofReceipt) -> Result<String> {
378 let mut markdown = String::new();
379 markdown.push_str("# DX Lane/Pass Claim\n\n");
380 markdown.push_str(&format!("- Lane: {}\n", output.claim.lane));
381 markdown.push_str(&format!("- Pass: {}\n", output.claim.pass));
382 markdown.push_str(&format!("- Worker: {}\n", output.claim.worker_id));
383 markdown.push_str(&format!(
384 "- Scope: {}\n",
385 escape_markdown_text(&output.claim.scope)
386 ));
387 markdown.push_str(&format!("- Next pass: {}\n", output.handoff.next_pass));
388 markdown.push_str(&format!(
389 "- Next action: {}\n",
390 escape_markdown_text(&output.handoff.next_action)
391 ));
392 markdown.push_str(&format!("- Worktree kind: {:?}\n\n", output.worktree.kind));
393 markdown.push_str(&format!(
394 "- Worktree decision: {:?}\n",
395 output.worktree_plan.creation_decision
396 ));
397 for blocker in &output.worktree_plan.blockers {
398 markdown.push_str(&format!(
399 "- Worktree blocker: {}\n",
400 escape_markdown_text(blocker)
401 ));
402 }
403 markdown.push('\n');
404 markdown.push_str("## Small Commands First\n");
405 for command in &output.recommended_small_commands {
406 markdown.push_str(&format!("- `{}`\n", command));
407 }
408 markdown.push('\n');
409 markdown.push_str(&receipt.render(ReceiptFormat::Markdown)?);
410 Ok(markdown)
411}
412
413fn render_assignment(assignment: &LanePassAssignment, json: bool) -> Result<String> {
414 if json {
415 return serde_json::to_string_pretty(assignment)
416 .map(|mut output| {
417 output.push('\n');
418 output
419 })
420 .map_err(|e| crate::DrivenError::Format(format!("failed to render JSON: {}", e)));
421 }
422
423 Ok(format!(
424 "# DX Lane/Pass Assignment\n\n- Status: {:?}\n- Scope: {}\n- Lane: {}\n- Pass: {}\n- Worker: {}\n- Counter: {}\n- Claims: {}\n",
425 assignment.status,
426 escape_markdown_text(&assignment.scope),
427 assignment.lane,
428 assignment.pass,
429 assignment.worker_id,
430 assignment.paths.counter_path.display(),
431 assignment.paths.claims_path.display()
432 ))
433}
434
435pub(super) fn state_config(
436 state_dir: PathBuf,
437 scope: &str,
438 options: &StrategyStateOptions,
439) -> LanePassConfig {
440 let config = LanePassConfig::new(state_dir, scope);
441 let config = if let Some(project_root) = &options.project_root {
442 config.with_project_root(project_root.clone())
443 } else {
444 match std::env::current_dir() {
445 Ok(project_root) => config.with_project_root(project_root),
446 Err(_) => config,
447 }
448 };
449 config
450 .with_lane_cycling(options.cycle_lanes)
451 .with_handoff_required_for_next(options.handoff_required_for_next)
452}
453
454pub(super) fn enforce_strict_isolation(
455 config: &LanePassConfig,
456 options: &StrategyStateOptions,
457) -> Result<()> {
458 if !options.strict_isolation {
459 return Ok(());
460 }
461 let project_root = match &config.project_root {
462 Some(project_root) => project_root.clone(),
463 None => std::env::current_dir().map_err(crate::DrivenError::Io)?,
464 };
465 let metadata = detect_worktree_metadata(&project_root);
466 let plan = WorktreeIsolationPlan::from_metadata(metadata);
467 if plan.native_isolation_detected && plan.blockers.is_empty() && plan.warnings.is_empty() {
468 return Ok(());
469 }
470 Err(crate::DrivenError::Validation(format!(
471 "strict isolation requires a clean isolated worktree; decision={:?}; blockers={}; warnings={}",
472 plan.creation_decision,
473 plan.blockers.join("; "),
474 plan.warnings.join("; ")
475 )))
476}
477
478pub(super) fn unix_seconds_now() -> Result<u64> {
479 SystemTime::now()
480 .duration_since(UNIX_EPOCH)
481 .map(|duration| duration.as_secs())
482 .map_err(|e| crate::DrivenError::Validation(format!("system time is before epoch: {}", e)))
483}
484
485pub(super) fn command_display(program: &str, args: &[String]) -> String {
486 std::iter::once(program.to_string())
487 .chain(args.iter().cloned())
488 .map(|arg| display_command_arg(&arg))
489 .collect::<Vec<_>>()
490 .join(" ")
491}
492
493fn display_command_arg(arg: &str) -> String {
494 if arg
495 .chars()
496 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | '\\' | ':'))
497 {
498 return arg.to_string();
499 }
500 format!("{:?}", arg)
501}
502
503fn escape_markdown_text(value: &str) -> String {
504 value
505 .replace('\r', "")
506 .replace('|', "\\|")
507 .replace('\n', "<br>")
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn claim_json_contains_lane_worker_and_next_action() {
516 let temp = tempfile::tempdir().unwrap();
517 let output = StrategyCommand::claim(
518 temp.path(),
519 2,
520 1,
521 "worker-cli",
522 "cli strategy",
523 "continue with receipts",
524 true,
525 )
526 .unwrap();
527
528 assert!(output.contains("\"lane\": 2"));
529 assert!(output.contains("\"worker_id\": \"worker-cli\""));
530 assert!(output.contains("continue with receipts"));
531 }
532
533 #[test]
534 fn claim_state_json_preserves_lane_across_next_pass() {
535 let temp = tempfile::tempdir().unwrap();
536 let state_dir = temp.path().join("state");
537
538 let claimed =
539 StrategyCommand::claim_state(state_dir.clone(), "cli state", 30, 3, "worker-cli", true)
540 .unwrap();
541 let next =
542 StrategyCommand::next_state(state_dir, "cli state", 30, 3, "worker-cli", true).unwrap();
543
544 assert!(claimed.contains("\"lane\": 1"));
545 assert!(claimed.contains("\"pass\": 1"));
546 assert!(next.contains("\"lane\": 1"));
547 assert!(next.contains("\"pass\": 2"));
548 }
549}