Skip to main content

mana/commands/
update.rs

1use std::path::Path;
2
3use anyhow::Result;
4use mana_core::ops::update as ops_update;
5
6/// Update a unit's fields based on provided flags.
7///
8/// - title, description, acceptance, design, priority, assignee, status: replace
9/// - notes: append with timestamp separator
10/// - labels: add/remove operations
11/// - updates updated_at and rebuilds index
12#[allow(clippy::too_many_arguments)]
13pub fn cmd_update(
14    mana_dir: &Path,
15    id: &str,
16    title: Option<String>,
17    description: Option<String>,
18    acceptance: Option<String>,
19    notes: Option<String>,
20    design: Option<String>,
21    status: Option<String>,
22    priority: Option<u8>,
23    assignee: Option<String>,
24    add_label: Option<String>,
25    remove_label: Option<String>,
26    decisions: Vec<String>,
27    resolve_decisions: Vec<String>,
28) -> Result<()> {
29    let result = ops_update::update(
30        mana_dir,
31        id,
32        ops_update::UpdateParams {
33            title,
34            description,
35            acceptance,
36            notes,
37            design,
38            status,
39            priority,
40            assignee,
41            add_label,
42            remove_label,
43            decisions,
44            resolve_decisions,
45        },
46    )?;
47
48    println!("Updated unit {}: {}", id, result.unit.title);
49    Ok(())
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use std::fs;
56
57    use crate::index::Index;
58    use crate::unit::{Status, Unit};
59    use crate::util::title_to_slug;
60    use tempfile::TempDir;
61
62    fn setup_test_mana_dir() -> (TempDir, std::path::PathBuf) {
63        let dir = TempDir::new().unwrap();
64        let mana_dir = dir.path().join(".mana");
65        fs::create_dir(&mana_dir).unwrap();
66        (dir, mana_dir)
67    }
68
69    #[test]
70    fn test_update_title() {
71        let (_dir, mana_dir) = setup_test_mana_dir();
72        let unit = Unit::new("1", "Original title");
73        let slug = title_to_slug(&unit.title);
74        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
75            .unwrap();
76
77        cmd_update(
78            &mana_dir,
79            "1",
80            Some("New title".to_string()),
81            None,
82            None,
83            None,
84            None,
85            None,
86            None,
87            None,
88            None,
89            None,
90            vec![],
91            vec![],
92        )
93        .unwrap();
94
95        let updated =
96            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
97        assert_eq!(updated.title, "New title");
98    }
99
100    #[test]
101    fn test_update_notes_appends() {
102        let (_dir, mana_dir) = setup_test_mana_dir();
103        let mut unit = Unit::new("1", "Test");
104        unit.notes = Some("First note".to_string());
105        let slug = title_to_slug(&unit.title);
106        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
107            .unwrap();
108
109        cmd_update(
110            &mana_dir,
111            "1",
112            None,
113            None,
114            None,
115            Some("Second note".to_string()),
116            None,
117            None,
118            None,
119            None,
120            None,
121            None,
122            vec![],
123            vec![],
124        )
125        .unwrap();
126
127        let updated =
128            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
129        let notes = updated.notes.unwrap();
130        assert!(notes.contains("First note"));
131        assert!(notes.contains("Second note"));
132        assert!(notes.contains("---"));
133    }
134
135    #[test]
136    fn test_update_notes_creates_with_timestamp() {
137        let (_dir, mana_dir) = setup_test_mana_dir();
138        let unit = Unit::new("1", "Test");
139        let slug = title_to_slug(&unit.title);
140        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
141            .unwrap();
142
143        cmd_update(
144            &mana_dir,
145            "1",
146            None,
147            None,
148            None,
149            Some("First note".to_string()),
150            None,
151            None,
152            None,
153            None,
154            None,
155            None,
156            vec![],
157            vec![],
158        )
159        .unwrap();
160
161        let updated =
162            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
163        let notes = updated.notes.unwrap();
164        assert!(notes.contains("First note"));
165        assert!(notes.contains("---"));
166        assert!(notes.contains("T")); // ISO 8601 has T for date-time
167    }
168
169    #[test]
170    fn test_update_status() {
171        let (_dir, mana_dir) = setup_test_mana_dir();
172        let unit = Unit::new("1", "Test");
173        let slug = title_to_slug(&unit.title);
174        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
175            .unwrap();
176
177        cmd_update(
178            &mana_dir,
179            "1",
180            None,
181            None,
182            None,
183            None,
184            None,
185            Some("in_progress".to_string()),
186            None,
187            None,
188            None,
189            None,
190            vec![],
191            vec![],
192        )
193        .unwrap();
194
195        let updated =
196            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
197        assert_eq!(updated.status, Status::InProgress);
198    }
199
200    #[test]
201    fn test_update_priority() {
202        let (_dir, mana_dir) = setup_test_mana_dir();
203        let unit = Unit::new("1", "Test");
204        let slug = title_to_slug(&unit.title);
205        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
206            .unwrap();
207
208        cmd_update(
209            &mana_dir,
210            "1",
211            None,
212            None,
213            None,
214            None,
215            None,
216            None,
217            Some(1),
218            None,
219            None,
220            None,
221            vec![],
222            vec![],
223        )
224        .unwrap();
225
226        let updated =
227            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
228        assert_eq!(updated.priority, 1);
229    }
230
231    #[test]
232    fn test_update_add_label() {
233        let (_dir, mana_dir) = setup_test_mana_dir();
234        let unit = Unit::new("1", "Test");
235        let slug = title_to_slug(&unit.title);
236        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
237            .unwrap();
238
239        cmd_update(
240            &mana_dir,
241            "1",
242            None,
243            None,
244            None,
245            None,
246            None,
247            None,
248            None,
249            None,
250            Some("urgent".to_string()),
251            None,
252            vec![],
253            vec![],
254        )
255        .unwrap();
256
257        let updated =
258            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
259        assert!(updated.labels.contains(&"urgent".to_string()));
260    }
261
262    #[test]
263    fn test_update_remove_label() {
264        let (_dir, mana_dir) = setup_test_mana_dir();
265        let mut unit = Unit::new("1", "Test");
266        unit.labels = vec!["urgent".to_string(), "bug".to_string()];
267        let slug = title_to_slug(&unit.title);
268        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
269            .unwrap();
270
271        cmd_update(
272            &mana_dir,
273            "1",
274            None,
275            None,
276            None,
277            None,
278            None,
279            None,
280            None,
281            None,
282            None,
283            Some("urgent".to_string()),
284            vec![],
285            vec![],
286        )
287        .unwrap();
288
289        let updated =
290            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
291        assert!(!updated.labels.contains(&"urgent".to_string()));
292        assert!(updated.labels.contains(&"bug".to_string()));
293    }
294
295    #[test]
296    fn test_update_nonexistent_unit() {
297        let (_dir, mana_dir) = setup_test_mana_dir();
298        let result = cmd_update(
299            &mana_dir,
300            "99",
301            Some("Title".to_string()),
302            None,
303            None,
304            None,
305            None,
306            None,
307            None,
308            None,
309            None,
310            None,
311            vec![],
312            vec![],
313        );
314        assert!(result.is_err());
315    }
316
317    #[test]
318    fn test_update_multiple_fields() {
319        let (_dir, mana_dir) = setup_test_mana_dir();
320        let unit = Unit::new("1", "Original");
321        let slug = title_to_slug(&unit.title);
322        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
323            .unwrap();
324
325        cmd_update(
326            &mana_dir,
327            "1",
328            Some("New title".to_string()),
329            Some("New desc".to_string()),
330            None,
331            None,
332            None,
333            Some("closed".to_string()),
334            Some(0),
335            None,
336            None,
337            None,
338            vec![],
339            vec![],
340        )
341        .unwrap();
342
343        let updated =
344            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
345        assert_eq!(updated.title, "New title");
346        assert_eq!(updated.description, Some("New desc".to_string()));
347        assert_eq!(updated.status, Status::Closed);
348        assert_eq!(updated.priority, 0);
349    }
350
351    #[test]
352    fn test_update_rebuilds_index() {
353        let (_dir, mana_dir) = setup_test_mana_dir();
354        let unit = Unit::new("1", "Original");
355        let slug = title_to_slug(&unit.title);
356        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
357            .unwrap();
358
359        // Index doesn't exist yet
360        assert!(!mana_dir.join("index.yaml").exists());
361
362        cmd_update(
363            &mana_dir,
364            "1",
365            Some("New title".to_string()),
366            None,
367            None,
368            None,
369            None,
370            None,
371            None,
372            None,
373            None,
374            None,
375            vec![],
376            vec![],
377        )
378        .unwrap();
379
380        // Index should be created
381        assert!(mana_dir.join("index.yaml").exists());
382
383        let index = Index::load(&mana_dir).unwrap();
384        assert_eq!(index.units.len(), 1);
385        assert_eq!(index.units[0].title, "New title");
386    }
387
388    #[test]
389    fn test_update_rejects_priority_too_high() {
390        let (_dir, mana_dir) = setup_test_mana_dir();
391        let unit = Unit::new("1", "Test");
392        let slug = title_to_slug(&unit.title);
393        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
394            .unwrap();
395
396        let result = cmd_update(
397            &mana_dir,
398            "1",
399            None,
400            None,
401            None,
402            None,
403            None,
404            None,
405            Some(5),
406            None,
407            None,
408            None,
409            vec![],
410            vec![],
411        );
412        assert!(result.is_err(), "Should reject priority > 4");
413        let err_msg = result.unwrap_err().to_string();
414        assert!(
415            err_msg.contains("priority"),
416            "Error should mention priority"
417        );
418    }
419
420    #[test]
421    fn test_update_accepts_valid_priorities() {
422        for priority in 0..=4 {
423            let (_dir, mana_dir) = setup_test_mana_dir();
424            let unit = Unit::new("1", "Test");
425            let slug = title_to_slug(&unit.title);
426            unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
427                .unwrap();
428
429            let result = cmd_update(
430                &mana_dir,
431                "1",
432                None,
433                None,
434                None,
435                None,
436                None,
437                None,
438                Some(priority),
439                None,
440                None,
441                None,
442                vec![],
443                vec![],
444            );
445            assert!(result.is_ok(), "Priority {} should be valid", priority);
446
447            let updated =
448                Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
449            assert_eq!(updated.priority, priority);
450        }
451    }
452
453    // =====================================================================
454    // Hook Tests
455    // =====================================================================
456
457    #[test]
458    fn test_pre_update_hook_skipped_when_not_trusted() {
459        let (_dir, mana_dir) = setup_test_mana_dir();
460        let unit = Unit::new("1", "Original");
461        let slug = title_to_slug(&unit.title);
462        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
463            .unwrap();
464
465        // Update should succeed even without hooks (untrusted)
466        let result = cmd_update(
467            &mana_dir,
468            "1",
469            Some("New title".to_string()),
470            None,
471            None,
472            None,
473            None,
474            None,
475            None,
476            None,
477            None,
478            None,
479            vec![],
480            vec![],
481        );
482        assert!(
483            result.is_ok(),
484            "Update should succeed when hooks not trusted"
485        );
486
487        let updated =
488            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
489        assert_eq!(updated.title, "New title");
490    }
491
492    #[test]
493    fn test_pre_update_hook_rejects_update_when_fails() {
494        use crate::hooks::create_trust;
495        use std::os::unix::fs::PermissionsExt;
496
497        let (dir, mana_dir) = setup_test_mana_dir();
498        let project_root = dir.path();
499        let unit = Unit::new("1", "Original");
500        let slug = title_to_slug(&unit.title);
501        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
502            .unwrap();
503
504        // Enable trust and create failing hook
505        create_trust(project_root).unwrap();
506        let hooks_dir = project_root.join(".mana").join("hooks");
507        fs::create_dir_all(&hooks_dir).unwrap();
508        let hook_path = hooks_dir.join("pre-update");
509        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
510
511        #[cfg(unix)]
512        {
513            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
514        }
515
516        // Update should fail
517        let result = cmd_update(
518            &mana_dir,
519            "1",
520            Some("New title".to_string()),
521            None,
522            None,
523            None,
524            None,
525            None,
526            None,
527            None,
528            None,
529            None,
530            vec![],
531            vec![],
532        );
533        assert!(
534            result.is_err(),
535            "Update should fail when pre-update hook rejects"
536        );
537        assert!(result.unwrap_err().to_string().contains("rejected"));
538
539        // Unit should not be modified
540        let unchanged =
541            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
542        assert_eq!(unchanged.title, "Original");
543    }
544
545    #[test]
546    fn test_pre_update_hook_allows_update_when_passes() {
547        use crate::hooks::create_trust;
548        use std::os::unix::fs::PermissionsExt;
549
550        let (dir, mana_dir) = setup_test_mana_dir();
551        let project_root = dir.path();
552        let unit = Unit::new("1", "Original");
553        let slug = title_to_slug(&unit.title);
554        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
555            .unwrap();
556
557        // Enable trust and create passing hook
558        create_trust(project_root).unwrap();
559        let hooks_dir = project_root.join(".mana").join("hooks");
560        fs::create_dir_all(&hooks_dir).unwrap();
561        let hook_path = hooks_dir.join("pre-update");
562        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
563
564        #[cfg(unix)]
565        {
566            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
567        }
568
569        // Update should succeed
570        let result = cmd_update(
571            &mana_dir,
572            "1",
573            Some("New title".to_string()),
574            None,
575            None,
576            None,
577            None,
578            None,
579            None,
580            None,
581            None,
582            None,
583            vec![],
584            vec![],
585        );
586        assert!(
587            result.is_ok(),
588            "Update should succeed when pre-update hook passes"
589        );
590
591        // Unit should be modified
592        let updated =
593            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
594        assert_eq!(updated.title, "New title");
595    }
596
597    #[test]
598    fn test_post_update_hook_runs_after_successful_update() {
599        use crate::hooks::create_trust;
600        use std::os::unix::fs::PermissionsExt;
601
602        let (dir, mana_dir) = setup_test_mana_dir();
603        let project_root = dir.path();
604        let unit = Unit::new("1", "Original");
605        let slug = title_to_slug(&unit.title);
606        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
607            .unwrap();
608
609        // Enable trust and create post-update hook that writes a marker file
610        create_trust(project_root).unwrap();
611        let hooks_dir = project_root.join(".mana").join("hooks");
612        fs::create_dir_all(&hooks_dir).unwrap();
613        let hook_path = hooks_dir.join("post-update");
614        let marker_path = project_root.join("post_update_ran");
615        let marker_path_str = marker_path.to_string_lossy();
616
617        fs::write(
618            &hook_path,
619            format!(
620                "#!/bin/bash\necho 'post-update hook ran' > {}",
621                marker_path_str
622            ),
623        )
624        .unwrap();
625
626        #[cfg(unix)]
627        {
628            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
629        }
630
631        // Update unit
632        cmd_update(
633            &mana_dir,
634            "1",
635            Some("New title".to_string()),
636            None,
637            None,
638            None,
639            None,
640            None,
641            None,
642            None,
643            None,
644            None,
645            vec![],
646            vec![],
647        )
648        .unwrap();
649
650        // Unit should be updated
651        let updated =
652            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
653        assert_eq!(updated.title, "New title");
654
655        // Post-update hook should have run (marker file created)
656        assert!(marker_path.exists(), "Post-update hook should have run");
657    }
658
659    #[test]
660    fn test_post_update_hook_failure_does_not_prevent_update() {
661        use crate::hooks::create_trust;
662        use std::os::unix::fs::PermissionsExt;
663
664        let (dir, mana_dir) = setup_test_mana_dir();
665        let project_root = dir.path();
666        let unit = Unit::new("1", "Original");
667        let slug = title_to_slug(&unit.title);
668        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
669            .unwrap();
670
671        // Enable trust and create failing post-update hook
672        create_trust(project_root).unwrap();
673        let hooks_dir = project_root.join(".mana").join("hooks");
674        fs::create_dir_all(&hooks_dir).unwrap();
675        let hook_path = hooks_dir.join("post-update");
676        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
677
678        #[cfg(unix)]
679        {
680            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
681        }
682
683        // Update should still succeed even though post-hook fails
684        let result = cmd_update(
685            &mana_dir,
686            "1",
687            Some("New title".to_string()),
688            None,
689            None,
690            None,
691            None,
692            None,
693            None,
694            None,
695            None,
696            None,
697            vec![],
698            vec![],
699        );
700        assert!(
701            result.is_ok(),
702            "Update should succeed even if post-update hook fails"
703        );
704
705        // Unit should still be modified
706        let updated =
707            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
708        assert_eq!(updated.title, "New title");
709    }
710
711    #[test]
712    fn test_update_with_multiple_fields_triggers_hooks() {
713        use crate::hooks::create_trust;
714        use std::os::unix::fs::PermissionsExt;
715
716        let (dir, mana_dir) = setup_test_mana_dir();
717        let project_root = dir.path();
718        let unit = Unit::new("1", "Original");
719        let slug = title_to_slug(&unit.title);
720        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
721            .unwrap();
722
723        // Enable trust and create hooks
724        create_trust(project_root).unwrap();
725        let hooks_dir = project_root.join(".mana").join("hooks");
726        fs::create_dir_all(&hooks_dir).unwrap();
727
728        let pre_hook = hooks_dir.join("pre-update");
729        fs::write(&pre_hook, "#!/bin/bash\nexit 0").unwrap();
730
731        let post_hook = hooks_dir.join("post-update");
732        fs::write(&post_hook, "#!/bin/bash\nexit 0").unwrap();
733
734        #[cfg(unix)]
735        {
736            fs::set_permissions(&pre_hook, fs::Permissions::from_mode(0o755)).unwrap();
737            fs::set_permissions(&post_hook, fs::Permissions::from_mode(0o755)).unwrap();
738        }
739
740        // Update multiple fields
741        let result = cmd_update(
742            &mana_dir,
743            "1",
744            Some("New title".to_string()),
745            Some("New desc".to_string()),
746            None,
747            None,
748            None,
749            Some("in_progress".to_string()),
750            None,
751            None,
752            None,
753            None,
754            vec![],
755            vec![],
756        );
757        assert!(result.is_ok());
758
759        // Verify all changes applied
760        let updated =
761            Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
762        assert_eq!(updated.title, "New title");
763        assert_eq!(updated.description, Some("New desc".to_string()));
764        assert_eq!(updated.status, Status::InProgress);
765    }
766}