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