1use std::path::{Path, PathBuf};
16
17use objects::object::StateId;
18use oplog::OpRecord;
19use repo::{GitImportGuidance, GitRemoteTrackingStatus, RepositoryOperationStatus, shell_quote};
20
21use crate::{
22 RepositoryVerificationState, ThreadPreviewReport,
23 status::next_action::{NextActionInput, effective_next_action, non_empty_action},
24};
25
26pub const AUTO_LAND_CONFIDENCE_THRESHOLD: f32 = 0.75;
28
29pub const AUTO_LAND_CONFIDENCE_RECOVERY_ACTION: &str =
31 "heddle capture -m \"...\" --confidence <confidence>";
32
33pub fn ready_verification_preflight_blocks(trust: &RepositoryVerificationState) -> bool {
39 ready_verification_status_blocks(trust.status.as_str())
40}
41
42pub fn ready_verification_status_blocks(status: &str) -> bool {
44 matches!(
45 status,
46 "needs_init" | "needs_import" | "needs_reconcile" | "git_branch_advanced"
47 )
48}
49
50pub fn has_integration_target(merge_relation: &str) -> bool {
56 merge_relation != "no_target"
57}
58
59pub fn is_integration_clear(conflict_count: usize, blockers: &[String]) -> bool {
61 conflict_count == 0 && blockers.is_empty()
62}
63
64#[derive(Debug, Clone, Copy)]
66pub struct ReadyDecisionInput<'a> {
67 pub merge_relation: &'a str,
68 pub captured: bool,
69 pub thread_already_ready: bool,
71 pub conflict_count: usize,
72 pub blockers: &'a [String],
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ReadyDecision {
78 pub has_integration_target: bool,
79 pub already_ready: bool,
81 pub ready_without_target: bool,
83 pub integration_clear: bool,
85 pub operator_completed: bool,
87}
88
89pub fn classify_ready_decision(input: ReadyDecisionInput<'_>) -> ReadyDecision {
91 let has_target = has_integration_target(input.merge_relation);
92 let clear = is_integration_clear(input.conflict_count, input.blockers);
93 let already_ready = has_target && !input.captured && input.thread_already_ready && clear;
94 let ready_without_target = !has_target && clear;
95 ReadyDecision {
96 has_integration_target: has_target,
97 already_ready,
98 ready_without_target,
99 integration_clear: clear,
100 operator_completed: !has_target || clear,
103 }
104}
105
106pub fn ready_report_recommended_action(
108 merge_relation: &str,
109 recommended_action: &str,
110) -> Option<String> {
111 if merge_relation == "no_target" {
112 return None;
113 }
114 non_empty_action(Some(recommended_action)).map(str::to_string)
115}
116
117pub fn ready_scoped_next_action(
119 operation: Option<&RepositoryOperationStatus>,
120 remote_tracking: Option<&GitRemoteTrackingStatus>,
121 import_hint: Option<&GitImportGuidance>,
122 thread_action: Option<&str>,
123) -> String {
124 effective_next_action(
125 NextActionInput::default(operation, remote_tracking, import_hint, thread_action).ready(),
126 )
127}
128
129pub fn should_squash_land(no_squash: bool, config_squash: bool) -> bool {
131 !no_squash && config_squash
132}
133
134#[derive(Debug, Clone, Copy)]
140pub struct AutoLandPolicyInput {
141 pub agent_authored: bool,
142 pub confidence: Option<f32>,
143 pub tests_passed: Option<bool>,
144}
145
146pub fn auto_land_policy_blockers(input: AutoLandPolicyInput) -> Vec<String> {
148 let mut blockers = Vec::new();
149 if input.agent_authored
150 && let Some(confidence) = input.confidence
151 && confidence < AUTO_LAND_CONFIDENCE_THRESHOLD
152 {
153 blockers.push(format!(
154 "confidence {:.2} is below the auto-land threshold of {AUTO_LAND_CONFIDENCE_THRESHOLD:.2}",
155 confidence
156 ));
157 }
158 if matches!(input.tests_passed, Some(false)) {
159 blockers.push("verification summary reports failing tests".to_string());
160 }
161 blockers
162}
163
164pub fn integration_blockers(
166 manual_resolution_current: bool,
167 preview_blockers: &[String],
168 policy: AutoLandPolicyInput,
169) -> Vec<String> {
170 let mut blockers = if manual_resolution_current {
171 Vec::new()
172 } else {
173 non_staleness_blockers(preview_blockers)
174 };
175 blockers.extend(auto_land_policy_blockers(policy));
176 blockers
177}
178
179pub fn integration_blocker_recommended_action(
181 blockers: &[String],
182 scope_to_checkout: Option<&Path>,
183) -> Option<String> {
184 blockers
185 .iter()
186 .any(|blocker| {
187 blocker.starts_with("confidence ")
188 || blocker == "verification summary reports failing tests"
189 })
190 .then(|| auto_land_confidence_recovery_action(scope_to_checkout))
191}
192
193pub fn auto_land_confidence_recovery_action(scope_to_checkout: Option<&Path>) -> String {
195 match scope_to_checkout {
196 Some(path) => format!(
197 "heddle --repo {} {}",
198 shell_quote(&path.display().to_string()),
199 AUTO_LAND_CONFIDENCE_RECOVERY_ACTION
200 .strip_prefix("heddle ")
201 .expect("recovery action is a heddle command"),
202 ),
203 None => AUTO_LAND_CONFIDENCE_RECOVERY_ACTION.to_string(),
204 }
205}
206
207pub fn recovery_scope_checkout(execution_path: &Path, current_checkout: &Path) -> Option<PathBuf> {
210 if execution_path.as_os_str().is_empty() {
211 return None;
212 }
213 let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
214 (canonical(execution_path) != canonical(current_checkout)).then(|| execution_path.to_path_buf())
215}
216
217pub fn is_heavy_impact_advisory(blocker: &str) -> bool {
223 blocker.to_lowercase().contains("heavy-impact change")
224}
225
226pub fn non_staleness_blockers(blockers: &[String]) -> Vec<String> {
228 blockers
229 .iter()
230 .filter(|blocker| {
231 !blocker.contains(" is stale against ") && !is_heavy_impact_advisory(blocker)
232 })
233 .cloned()
234 .collect()
235}
236
237pub fn land_blockers_for_preview(
239 preview: &ThreadPreviewReport,
240 blockers: &[String],
241) -> Vec<String> {
242 let mut out = blockers.to_vec();
243 if preview.conflict_count > 0 {
244 out.push(format!(
245 "{} path conflict(s) need manual resolution",
246 preview.conflict_count
247 ));
248 out.extend(
249 preview
250 .conflicts
251 .iter()
252 .map(|path| format!("conflict: {path}")),
253 );
254 }
255 out.sort();
256 out.dedup();
257 out
258}
259
260pub fn land_warnings_for_preview(preview: &ThreadPreviewReport) -> Vec<String> {
262 let mut warnings = preview
263 .blockers
264 .iter()
265 .filter(|blocker| is_heavy_impact_advisory(blocker))
266 .cloned()
267 .collect::<Vec<_>>();
268 if warnings.is_empty() && !preview.heavy_impact_paths.is_empty() {
269 warnings.push(format!(
270 "Heavy-impact change: {} — review broader impact before merging",
271 preview.heavy_impact_paths.join(", ")
272 ));
273 }
274 warnings.sort();
275 warnings.dedup();
276 warnings
277}
278
279pub fn is_manual_review_blocker(blocker: &str) -> bool {
285 blocker.starts_with("Heavy-impact change:")
286}
287
288pub fn land_text_step(step: &str) -> String {
290 match step {
291 "capture" => "saved".to_string(),
292 "sync" => "refreshed".to_string(),
293 "merge" => "merged".to_string(),
294 "checkpoint" => "committed".to_string(),
295 "capture(no changes)" => "no unsaved changes".to_string(),
296 "sync(current)" => "already refreshed".to_string(),
297 "merge(blocked)" => "merge blocked".to_string(),
298 "merge(already_integrated)" => "already landed".to_string(),
299 "checkpoint(not needed)" => "no Git commit needed".to_string(),
300 "checkpoint(not reached)" => "Git commit skipped because merge did not run".to_string(),
301 other => other.to_string(),
302 }
303}
304
305pub fn scope_action_to_repo(action: &str, repo_path: &str) -> String {
307 let Some(rest) = action.strip_prefix("heddle ") else {
308 return action.to_string();
309 };
310 if rest.starts_with("--repo ") || rest.starts_with("-R ") {
311 return action.to_string();
312 }
313 format!(
314 "heddle --repo {} {rest}",
315 quote_recommended_action_arg(repo_path)
316 )
317}
318
319pub fn quote_recommended_action_arg(value: &str) -> String {
321 if !value.is_empty()
322 && value
323 .bytes()
324 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'))
325 {
326 value.to_string()
327 } else {
328 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
329 }
330}
331
332pub fn ready_merge_type_label(result: &str) -> String {
334 match result {
335 "fast_forward" => "fast-forward".to_string(),
336 "already_integrated" => "already integrated".to_string(),
337 "no_target" => "none configured".to_string(),
338 other => other.replace('_', " "),
339 }
340}
341
342pub fn ready_status_summary(
344 merge_relation: &str,
345 blockers_empty: bool,
346 thread_health: &str,
347) -> String {
348 if merge_relation == "no_target" && blockers_empty {
349 "clean".to_string()
350 } else {
351 thread_health.replace('_', " ")
352 }
353}
354
355pub fn ready_integration_summary(merge_relation: &str) -> String {
357 match merge_relation {
358 "no_target" => "n/a (no integration target configured)".to_string(),
359 "not_checked" => "not checked (readiness checks did not run)".to_string(),
360 "blocked" => "not checked (repository verification is blocked)".to_string(),
361 _ => "configured".to_string(),
362 }
363}
364
365pub fn ready_freshness_summary(merge_relation: &str, freshness: &str) -> String {
367 match merge_relation {
368 "no_target" => "n/a (no integration target configured)".to_string(),
369 "not_checked" => "not checked (readiness checks did not run)".to_string(),
370 "blocked" => "not checked (repository verification is blocked)".to_string(),
371 _ => freshness.replace('_', " "),
372 }
373}
374
375pub fn ready_merge_type_summary(merge_relation: &str) -> String {
377 match merge_relation {
378 "no_target" => "n/a (no integration target configured)".to_string(),
379 "not_checked" => "not checked (readiness checks did not run)".to_string(),
380 "blocked" => "not checked (repository verification is blocked)".to_string(),
381 other => ready_merge_type_label(other),
382 }
383}
384
385pub fn land_performed_steps(
387 captured: bool,
388 synced: bool,
389 integrated: bool,
390 checkpointed: bool,
391) -> Vec<String> {
392 [
393 (captured, "capture"),
394 (synced, "sync"),
395 (integrated, "merge"),
396 (checkpointed, "checkpoint"),
397 ]
398 .into_iter()
399 .filter(|&(done, _step)| done)
400 .map(|(_done, step)| step.to_string())
401 .collect()
402}
403
404pub fn land_skipped_steps(
406 captured: bool,
407 synced: bool,
408 integrated: bool,
409 checkpointed: bool,
410) -> Vec<String> {
411 [
412 (!captured, "capture(no changes)"),
413 (!synced, "sync(current)"),
414 (!integrated, "merge(blocked)"),
415 (!checkpointed && integrated, "checkpoint(not needed)"),
416 (!checkpointed && !integrated, "checkpoint(not reached)"),
417 ]
418 .into_iter()
419 .filter(|&(skipped, _step)| skipped)
420 .map(|(_skipped, step)| step.to_string())
421 .collect()
422}
423
424pub fn sync_completed_next_action(
430 operation: Option<&RepositoryOperationStatus>,
431 remote_tracking: Option<&GitRemoteTrackingStatus>,
432 import_hint: Option<&GitImportGuidance>,
433) -> Option<String> {
434 non_empty_action(Some(&effective_next_action(NextActionInput::default(
435 operation,
436 remote_tracking,
437 import_hint,
438 None,
439 ))))
440 .map(str::to_string)
441}
442
443pub fn sync_is_already_current(freshness_is_current: bool, is_synthesized_checkout: bool) -> bool {
451 freshness_is_current || is_synthesized_checkout
452}
453
454pub fn integrated_land_next_action(
456 integrated: bool,
457 trust_recommended_action: &str,
458) -> Option<String> {
459 if !integrated {
460 return None;
461 }
462 if trust_recommended_action == "heddle push" {
463 Some(trust_recommended_action.to_string())
464 } else {
465 Some("heddle thread cleanup --merged --dry-run".to_string())
466 }
467}
468
469pub fn land_checkpoint_message(
475 explicit: Option<&str>,
476 prefer_land_subject: bool,
477 thread_name: &str,
478 intent: Option<&str>,
479 task: Option<&str>,
480) -> String {
481 if let Some(message) = explicit {
482 return git_subject_message(message);
483 }
484 let task = task.filter(|task| !subject_has_machine_identity(task));
485 let name = (!looks_like_machine_identity(thread_name)).then_some(thread_name);
486 let intent = intent.filter(|intent| !subject_has_machine_identity(intent));
487 let chosen = if prefer_land_subject {
488 task.or(name).or(intent)
489 } else {
490 intent.or(task).or(name)
491 };
492 git_subject_message(chosen.unwrap_or("Land thread"))
493}
494
495pub fn git_subject_message(message: &str) -> String {
497 let chosen = message.trim();
498 let first_line = chosen.lines().next().unwrap_or(chosen);
499 let subject = first_line.trim();
500 let detail = chosen[first_line.len()..].trim();
501 if subject.chars().count() <= 72 {
502 return if detail.is_empty() {
503 subject.to_string()
504 } else {
505 format!("{subject}\n\n{detail}")
506 };
507 }
508 let prefix: String = subject.chars().take(72).collect();
509 let short = prefix
510 .rsplit_once(char::is_whitespace)
511 .map(|(head, _)| head)
512 .filter(|head| !head.is_empty())
513 .unwrap_or(&prefix);
514 format!("{}\n\n{chosen}", short.trim_end())
515}
516
517pub fn looks_like_machine_identity(value: &str) -> bool {
519 let text = value.trim();
520 if text.is_empty() {
521 return true;
522 }
523 let uuid = text.len() == 36
524 && text.bytes().enumerate().all(|(i, byte)| {
525 if [8, 13, 18, 23].contains(&i) {
526 byte == b'-'
527 } else {
528 byte.is_ascii_hexdigit()
529 }
530 });
531 let long_hex = text.len() >= 16 && text.bytes().all(|byte| byte.is_ascii_hexdigit());
532 let prefixed = ["principal:", "agent:", "device:", "device-key:"]
533 .iter()
534 .any(|prefix| text.to_ascii_lowercase().starts_with(prefix));
535 let keyed = text.split_once(':').is_some_and(|(key, value)| {
536 key.len() >= 8
537 && key
538 .bytes()
539 .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
540 && value
541 .split(|ch: char| !ch.is_ascii_hexdigit())
542 .any(|part| part.len() >= 16)
543 });
544 uuid || long_hex || prefixed || keyed || text.starts_with("spool:git:")
545}
546
547pub fn machine_identity_span(text: &str) -> Option<(usize, usize)> {
549 for (start, ch) in text.char_indices() {
550 if !ch.is_ascii_hexdigit() {
551 continue;
552 }
553 let rest = &text[start..];
554 if rest.as_bytes().get(..36).is_some_and(|candidate| {
555 candidate.iter().copied().enumerate().all(|(index, byte)| {
556 if [8, 13, 18, 23].contains(&index) {
557 byte == b'-'
558 } else {
559 byte.is_ascii_hexdigit()
560 }
561 })
562 }) {
563 return Some((start, start + 36));
564 }
565 let hex_len = rest.bytes().take_while(u8::is_ascii_hexdigit).count();
566 if hex_len >= 16 {
567 return Some((start, start + hex_len));
568 }
569 }
570 None
571}
572
573pub fn subject_has_machine_identity(message: &str) -> bool {
575 let subject = message.lines().next().unwrap_or_default();
576 subject.trim().is_empty()
577 || machine_identity_span(subject).is_some()
578 || subject
579 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'))
580 .filter(|token| !token.is_empty())
581 .any(looks_like_machine_identity)
582}
583
584pub fn state_id_matches_display(short: &str, full: &str, display: &str) -> bool {
586 short == display || full == display
587}
588
589pub fn op_targets_merge_state(op: &OpRecord, merge_state: &str) -> bool {
591 match op {
592 OpRecord::Snapshot { new_state, .. } => {
593 state_id_matches_display(&new_state.short(), &new_state.to_string_full(), merge_state)
594 }
595 OpRecord::Checkpoint { state, .. } => {
596 state_id_matches_display(&state.short(), &state.to_string_full(), merge_state)
597 }
598 OpRecord::Goto { target, .. } => {
599 state_id_matches_display(&target.short(), &target.to_string_full(), merge_state)
600 }
601 OpRecord::FastForward { post_target_id, .. } => state_id_matches_display(
602 &post_target_id.short(),
603 &post_target_id.to_string_full(),
604 merge_state,
605 ),
606 OpRecord::ThreadCreate { .. }
609 | OpRecord::ThreadDelete { .. }
610 | OpRecord::ThreadUpdate { .. }
611 | OpRecord::Fork { .. }
612 | OpRecord::Collapse { .. }
613 | OpRecord::MarkerCreate { .. }
614 | OpRecord::MarkerDelete { .. }
615 | OpRecord::TransactionAbort { .. }
616 | OpRecord::EphemeralThreadCollapse { .. }
617 | OpRecord::ConflictResolved { .. }
618 | OpRecord::TransactionCommit { .. }
619 | OpRecord::Redact { .. }
620 | OpRecord::Purge { .. }
621 | OpRecord::GitCheckpoint { .. }
622 | OpRecord::RemoteThreadUpdate { .. }
623 | OpRecord::RemoteThreadDelete { .. }
624 | OpRecord::UndoRecoveryUpdate { .. }
625 | OpRecord::StateVisibilitySet { .. }
626 | OpRecord::StateVisibilityPromote { .. }
627 | OpRecord::EntryVisibilitySet { .. }
628 | OpRecord::HeadUpdate { .. } => false,
629 }
630}
631
632pub fn state_id_matches_op_display(id: &StateId, display: &str) -> bool {
634 state_id_matches_display(&id.short(), &id.to_string_full(), display)
635}
636
637#[cfg(test)]
638mod tests {
639 use repo::{OperationKind, OperationScope};
640
641 use super::*;
642 use crate::status::next_action as core_next_action;
643
644 fn bare_trust(status: &str) -> RepositoryVerificationState {
645 RepositoryVerificationState {
646 verified: false,
647 status: status.to_string(),
648 repository_mode: "native".to_string(),
649 heddle_initialized: true,
650 git_branch: None,
651 heddle_thread: None,
652 worktree_dirty: false,
653 worktree_state: "clean".to_string(),
654 import_state: "ok".to_string(),
655 mapping_state: "ok".to_string(),
656 remote_drift: "none".to_string(),
657 active_operation: None,
658 default_remote: None,
659 clone_verification: "not_applicable".to_string(),
660 machine_contract: "not_checked".to_string(),
661 machine_contract_coverage: crate::MachineContractCoverage::not_checked(),
662 workflow_status: "idle".to_string(),
663 workflow_summary: String::new(),
664 summary: status.to_string(),
665 recommended_action: "heddle verify".to_string(),
666 recommended_action_template: None,
667 recovery_commands: Vec::new(),
668 recovery_action_templates: Vec::new(),
669 checks: Vec::new(),
670 }
671 }
672
673 fn preview(merge_relation: &str) -> ThreadPreviewReport {
674 ThreadPreviewReport {
675 thread: "feature".to_string(),
676 thread_mode: "solid".to_string(),
677 thread_state: "ready".to_string(),
678 freshness: "current".to_string(),
679 task: None,
680 changed_paths: Vec::new(),
681 changed_path_count: 0,
682 impact_categories: Vec::new(),
683 heavy_impact_paths: Vec::new(),
684 merge_relation: merge_relation.to_string(),
685 conflicts: Vec::new(),
686 conflict_count: 0,
687 blockers: Vec::new(),
688 recommended_action: "heddle land --thread feature".to_string(),
689 recommended_action_template: None,
690 thread_health: "ready".to_string(),
691 }
692 }
693
694 #[test]
695 fn ready_preflight_blocks_setup_and_mapping_statuses() {
696 for status in [
697 "needs_init",
698 "needs_import",
699 "needs_reconcile",
700 "git_branch_advanced",
701 ] {
702 assert!(
703 ready_verification_preflight_blocks(&bare_trust(status)),
704 "{status} should block ready preflight"
705 );
706 }
707 assert!(!ready_verification_preflight_blocks(&bare_trust("clean")));
708 assert!(!ready_verification_preflight_blocks(&bare_trust(
709 "dirty_worktree"
710 )));
711 }
712
713 #[test]
714 fn ready_decision_classifies_already_ready_and_no_target() {
715 let clear = classify_ready_decision(ReadyDecisionInput {
716 merge_relation: "fast_forward",
717 captured: false,
718 thread_already_ready: true,
719 conflict_count: 0,
720 blockers: &[],
721 });
722 assert!(clear.already_ready);
723 assert!(clear.integration_clear);
724 assert!(clear.operator_completed);
725
726 let no_target = classify_ready_decision(ReadyDecisionInput {
727 merge_relation: "no_target",
728 captured: false,
729 thread_already_ready: false,
730 conflict_count: 0,
731 blockers: &[],
732 });
733 assert!(no_target.ready_without_target);
734 assert!(!no_target.has_integration_target);
735 assert!(no_target.operator_completed);
736
737 let blocked = classify_ready_decision(ReadyDecisionInput {
738 merge_relation: "conflicted",
739 captured: false,
740 thread_already_ready: false,
741 conflict_count: 1,
742 blockers: &["conflict".to_string()],
743 });
744 assert!(!blocked.integration_clear);
745 assert!(!blocked.operator_completed);
746 }
747
748 #[test]
749 fn ready_suppresses_action_without_target() {
750 assert_eq!(
751 ready_report_recommended_action("no_target", "heddle land --thread main"),
752 None
753 );
754 assert_eq!(
755 ready_report_recommended_action("fast_forward", "heddle land --thread feature"),
756 Some("heddle land --thread feature".to_string())
757 );
758 }
759
760 #[test]
761 fn ready_scoped_next_action_matches_core_matrix() {
762 let operation = RepositoryOperationStatus {
763 scope: OperationScope::Heddle,
764 kind: OperationKind::Merge,
765 in_progress: true,
766 state: "in_progress".to_string(),
767 message: "merge in progress".to_string(),
768 next_action: "heddle continue".to_string(),
769 };
770 let remote_ahead = GitRemoteTrackingStatus {
771 branch: "feature".to_string(),
772 upstream: "origin/feature".to_string(),
773 ahead: 1,
774 behind: 0,
775 local_oid: Some("local".to_string()),
776 upstream_oid: Some("upstream".to_string()),
777 upstream_is_undone_checkpoint: false,
778 message: String::new(),
779 next_action: String::new(),
780 };
781 let fallback = Some("heddle land --thread feature");
782 let scoped = ready_scoped_next_action(Some(&operation), None, None, fallback);
783 let core = core_next_action::effective_next_action(
784 core_next_action::NextActionInput::default(Some(&operation), None, None, fallback)
785 .ready(),
786 );
787 assert_eq!(scoped, core);
788 assert_eq!(scoped, "heddle continue");
789
790 let publish = ready_scoped_next_action(None, Some(&remote_ahead), None, None);
791 assert_eq!(
792 publish,
793 core_next_action::effective_next_action(
794 core_next_action::NextActionInput::default(None, Some(&remote_ahead), None, None,)
795 .ready(),
796 )
797 );
798 }
799
800 #[test]
801 fn auto_land_policy_blocks_low_confidence_and_failing_tests() {
802 let blockers = auto_land_policy_blockers(AutoLandPolicyInput {
803 agent_authored: true,
804 confidence: Some(0.40),
805 tests_passed: Some(false),
806 });
807 assert_eq!(
808 blockers,
809 vec![
810 "confidence 0.40 is below the auto-land threshold of 0.75".to_string(),
811 "verification summary reports failing tests".to_string(),
812 ]
813 );
814 assert!(
815 auto_land_policy_blockers(AutoLandPolicyInput {
816 agent_authored: false,
817 confidence: Some(0.10),
818 tests_passed: Some(true),
819 })
820 .is_empty()
821 );
822 }
823
824 #[test]
825 fn confidence_blocker_recovery_scopes_to_thread_checkout() {
826 let blockers = vec!["confidence 0.40 is below the auto-land threshold of 0.75".to_string()];
827 let action = integration_blocker_recommended_action(
828 &blockers,
829 Some(Path::new("/work/threads/agent-thread")),
830 )
831 .expect("confidence blocker must yield recovery");
832 assert_eq!(
833 action,
834 "heddle --repo /work/threads/agent-thread capture -m \"...\" --confidence <confidence>"
835 );
836
837 let unscoped =
838 integration_blocker_recommended_action(&blockers, None).expect("unscoped recovery");
839 assert_eq!(unscoped, AUTO_LAND_CONFIDENCE_RECOVERY_ACTION);
840
841 assert!(
842 integration_blocker_recommended_action(
843 &["3 path conflict(s) need manual resolution".to_string()],
844 None
845 )
846 .is_none()
847 );
848 }
849
850 #[test]
851 fn non_staleness_drops_stale_and_heavy_impact() {
852 let blockers = vec![
853 "Thread 'agent-thread' is stale against 'main'".to_string(),
854 "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
855 .to_string(),
856 "confidence 0.40 is below the auto-land threshold of 0.75".to_string(),
857 ];
858 assert_eq!(
859 non_staleness_blockers(&blockers),
860 vec!["confidence 0.40 is below the auto-land threshold of 0.75".to_string()]
861 );
862 }
863
864 #[test]
865 fn land_warnings_surface_heavy_impact_review() {
866 let mut report = preview("would_merge");
867 report.heavy_impact_paths = vec!["crates/wire/src/lib.rs".to_string()];
868 report.blockers = vec![
869 "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
870 .to_string(),
871 ];
872 assert_eq!(
873 land_warnings_for_preview(&report),
874 vec![
875 "Heavy-impact change: crates/wire/src/lib.rs — review broader impact before merging"
876 .to_string()
877 ]
878 );
879 }
880
881 #[test]
882 fn land_step_accounting_and_next_action() {
883 assert_eq!(
884 land_performed_steps(true, false, true, true),
885 vec!["capture", "merge", "checkpoint"]
886 );
887 assert!(land_skipped_steps(true, true, true, true).is_empty());
888 assert_eq!(
889 integrated_land_next_action(true, "heddle push"),
890 Some("heddle push".to_string())
891 );
892 assert_eq!(
893 integrated_land_next_action(true, "heddle push"),
894 Some("heddle push".to_string())
895 );
896 assert_eq!(integrated_land_next_action(false, "heddle push"), None);
897 }
898
899 #[test]
900 fn sync_completed_next_action_is_not_land_thread() {
901 assert_eq!(sync_completed_next_action(None, None, None), None);
902
903 let operation = RepositoryOperationStatus {
904 scope: OperationScope::Heddle,
905 kind: OperationKind::Merge,
906 in_progress: true,
907 state: "in_progress".to_string(),
908 message: "merge in progress".to_string(),
909 next_action: "heddle continue".to_string(),
910 };
911 assert_eq!(
912 sync_completed_next_action(Some(&operation), None, None),
913 Some("heddle continue".to_string())
914 );
915
916 assert!(sync_is_already_current(true, false));
917 assert!(sync_is_already_current(false, true));
918 assert!(!sync_is_already_current(false, false));
919 }
920
921 #[test]
922 fn recovery_scope_checkout_distinguishes_isolated_from_in_thread() {
923 assert_eq!(
924 recovery_scope_checkout(
925 Path::new("/work/threads/agent-thread"),
926 Path::new("/work/parent"),
927 ),
928 Some(PathBuf::from("/work/threads/agent-thread")),
929 );
930 assert_eq!(
931 recovery_scope_checkout(
932 Path::new("/work/threads/agent-thread"),
933 Path::new("/work/threads/agent-thread"),
934 ),
935 None,
936 );
937 assert_eq!(
938 recovery_scope_checkout(Path::new(""), Path::new("/work/parent")),
939 None,
940 );
941 }
942
943 #[test]
944 fn should_squash_respects_no_squash_and_config() {
945 assert!(should_squash_land(false, true));
946 assert!(!should_squash_land(true, true));
947 assert!(!should_squash_land(false, false));
948 }
949
950 #[test]
951 fn land_checkpoint_message_precedence() {
952 assert_eq!(
953 land_checkpoint_message(Some("explicit"), false, "t", Some("intent"), Some("task")),
954 "explicit"
955 );
956 assert_eq!(
957 land_checkpoint_message(Some(" "), true, "t", Some("intent"), None),
958 ""
959 );
960 assert_eq!(
961 land_checkpoint_message(None, false, "t", Some("intent"), Some("task")),
962 "intent"
963 );
964 assert_eq!(
965 land_checkpoint_message(None, false, "t", None, Some("task")),
966 "task"
967 );
968 assert_eq!(land_checkpoint_message(None, false, "t", None, None), "t");
969 }
970
971 #[test]
972 fn land_checkpoint_subject_uses_task_instead_of_hex_thread_id() {
973 let id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
974 let subject = land_checkpoint_message(
975 None,
976 true,
977 id,
978 Some("First capture intent"),
979 Some("Improve search results"),
980 );
981 assert_eq!(subject, "Improve search results");
982 assert!(!subject.contains(id));
983 assert_eq!(
984 land_checkpoint_message(None, true, id, Some("Add search index"), None),
985 "Add search index"
986 );
987 }
988
989 #[test]
990 fn land_checkpoint_honors_explicit_message_with_64_hex_identity() {
991 let id = "a".repeat(64);
992 let explicit = format!("Revert {id}");
993 let subject = land_checkpoint_message(
994 Some(&explicit),
995 true,
996 "search",
997 None,
998 Some("Improve search results"),
999 );
1000 assert_eq!(subject.lines().next(), Some(explicit.as_str()));
1001 assert_eq!(subject, explicit);
1002 }
1003
1004 #[test]
1005 fn land_checkpoint_long_task_keeps_subject_short_and_detail_in_body() {
1006 let task = "Improve search ranking so results from multiple sources stay relevant and readable for people";
1007 let message = land_checkpoint_message(None, true, "feature/search", None, Some(task));
1008 let subject = message.lines().next().unwrap_or_default();
1009 assert!(subject.chars().count() <= 72, "{message}");
1010 assert!(message.contains(&format!("\n\n{task}")), "{message}");
1011 }
1012
1013 #[test]
1014 fn state_id_matches_short_or_full() {
1015 assert!(state_id_matches_display("abc", "abcdef", "abc"));
1016 assert!(state_id_matches_display("abc", "abcdef", "abcdef"));
1017 assert!(!state_id_matches_display("abc", "abcdef", "zzz"));
1018 }
1019
1020 #[test]
1021 fn land_text_scope_and_manual_review() {
1022 assert_eq!(land_text_step("capture"), "saved");
1023 assert_eq!(land_text_step("merge(blocked)"), "merge blocked");
1024 assert_eq!(
1025 land_text_step("merge(already_integrated)"),
1026 "already landed"
1027 );
1028 assert_eq!(
1029 land_text_step("checkpoint(not reached)"),
1030 "Git commit skipped because merge did not run"
1031 );
1032 assert!(is_manual_review_blocker("Heavy-impact change: Cargo.lock"));
1033 assert!(!is_manual_review_blocker("stale"));
1034 assert_eq!(
1035 scope_action_to_repo("heddle land main", "/tmp/repo"),
1036 "heddle --repo /tmp/repo land main"
1037 );
1038 assert_eq!(ready_merge_type_label("fast_forward"), "fast-forward");
1039 assert_eq!(
1040 ready_merge_type_label("already_integrated"),
1041 "already integrated"
1042 );
1043 assert_eq!(ready_merge_type_label("no_target"), "none configured");
1044 }
1045}