@@ -9,7 +9,7 @@ use std::fmt::Write as _;
99use clap:: { Args , Parser , Subcommand , ValueEnum } ;
1010
1111use voro_core:: {
12- Action , AgentsConfig , DepKind , Doc , NewTask , PrRef , Priority , Project , QueueRow , Repo ,
12+ Action , AgentsConfig , DepKind , Doc , Event , NewTask , PrRef , Priority , Project , QueueRow , Repo ,
1313 ReviewAction , ReviewMedium , Store , Task , TaskEdit , TaskState , Triage , WipGate , scheduler,
1414} ;
1515
@@ -100,11 +100,18 @@ tasks
100100 discovered-from that task (dispatch renders
101101 the flag with the running task's id)
102102 set <task-id> [--title T] [--priority 0-3] [--agent NAME | --no-agent]
103- [--body TEXT | --body-file PATH] [--blocked-by IDS] [--blocks IDS]
104- [--unlink KIND:ID] [--pr URL | --no-pr] [--branch NAME | --no-branch]
103+ [--body TEXT | --body-file PATH] [--append-body TEXT | --append-body-file PATH]
104+ [--allow-empty] [--blocked-by IDS] [--blocks IDS] [--unlink KIND:ID]
105+ [--pr URL | --no-pr] [--branch NAME | --no-branch]
105106 [--human | --no-human] [--deep | --no-deep]
106107 [--summary TEXT | --summary-file PATH]
107108 [--repo NAME | --no-repo] [--doc DOCS | --no-doc]
109+ --body replaces the whole body; --append-body
110+ adds to what is there, after a blank line.
111+ A replacement that would leave the body empty
112+ is refused unless --allow-empty; either way
113+ the replaced text is kept on the event log
114+ (`show` names the event to recover it from)
108115 --blocked-by replaces this task's own
109116 blocker list; --blocks adds this task as a
110117 blocker of each listed task
@@ -128,7 +135,11 @@ tasks
128135 --doc replaces the task's whole document list
129136 (`voro doc link` adds one without listing the
130137 rest); --no-doc clears it
131- show <task-id> full task: body, docs, deps, events
138+ show <task-id> [--event EVENT-ID]
139+ full task: body, docs, deps, events. --event
140+ prints one event's recorded detail and nothing
141+ else, which is how a replaced body comes back:
142+ `voro show 62 --event 512 > body.md`
132143 list [--state STATE] [--project P] [--doc DOC]
133144 --doc answers 'which tasks derive from this
134145 plan?'
@@ -261,6 +272,8 @@ enum Verb {
261272 Set ( SetArgs ) ,
262273 Show {
263274 task_id : i64 ,
275+ #[ arg( long, value_name = "EVENT-ID" ) ]
276+ event : Option < i64 > ,
264277 } ,
265278 List ( ListArgs ) ,
266279 Inbox ,
@@ -480,6 +493,12 @@ struct SetArgs {
480493 body : Option < String > ,
481494 #[ arg( long, conflicts_with = "body" ) ]
482495 body_file : Option < String > ,
496+ #[ arg( long, conflicts_with_all = [ "body" , "body_file" ] ) ]
497+ append_body : Option < String > ,
498+ #[ arg( long, conflicts_with_all = [ "body" , "body_file" , "append_body" ] ) ]
499+ append_body_file : Option < String > ,
500+ #[ arg( long) ]
501+ allow_empty : bool ,
483502 #[ arg( long) ]
484503 blocked_by : Option < String > ,
485504 #[ arg( long) ]
@@ -624,7 +643,10 @@ pub fn run(store: &mut Store, args: Vec<String>, ctx: &DispatchCtx) -> Result<St
624643 Verb :: Add ( args) => add_verb ( store, args) ,
625644 Verb :: Propose ( args) => propose_verb ( store, args) ,
626645 Verb :: Set ( args) => set_verb ( store, args) ,
627- Verb :: Show { task_id } => show_verb ( store, task_id) ,
646+ Verb :: Show { task_id, event } => match event {
647+ Some ( event_id) => show_event ( store, task_id, event_id) ,
648+ None => show_verb ( store, task_id) ,
649+ } ,
628650 Verb :: List ( args) => list_verb ( store, & args) ,
629651 Verb :: Inbox => inbox_verb ( store, ctx) ,
630652 Verb :: Next => next_verb ( store) ,
@@ -759,6 +781,59 @@ fn text_or_file(text: Option<String>, path: Option<String>) -> Result<Option<Str
759781 }
760782}
761783
784+ /// The body a `set` lands on (DESIGN.md §8). `--body`/`--body-file` replace it
785+ /// wholesale; `--append-body`/`--append-body-file` add to what is already there
786+ /// after a blank line, which is the "record a finding on the task" case that
787+ /// otherwise gets spelled as a replacement and loses the brief. A replacement
788+ /// that would leave a non-empty body empty is refused unless `--allow-empty`,
789+ /// since nothing legitimate reads as "blank the brief" and the commonest way to
790+ /// arrive at one is a slip. Emptying an already-empty body destroys nothing and
791+ /// is left alone.
792+ fn set_body ( current : & str , args : & mut SetArgs , id : i64 ) -> Result < String , String > {
793+ if let Some ( added) = text_or_file ( args. append_body . take ( ) , args. append_body_file . take ( ) ) ? {
794+ return Ok ( appended_body ( current, & added) ) ;
795+ }
796+ let Some ( replacement) = text_or_file ( args. body . take ( ) , args. body_file . take ( ) ) ? else {
797+ return Ok ( current. to_string ( ) ) ;
798+ } ;
799+ if replacement. trim ( ) . is_empty ( ) && !current. trim ( ) . is_empty ( ) && !args. allow_empty {
800+ return Err ( format ! (
801+ "refusing to empty the body of task {id} ({} lines) — pass --allow-empty if you mean it" ,
802+ current. lines( ) . count( )
803+ ) ) ;
804+ }
805+ Ok ( replacement)
806+ }
807+
808+ /// An addition to an existing body, separated from it by one blank line. An
809+ /// empty body takes the addition as-is, so the first append reads like a write.
810+ fn appended_body ( current : & str , added : & str ) -> String {
811+ let base = current. trim_end ( ) ;
812+ if base. is_empty ( ) {
813+ return added. to_string ( ) ;
814+ }
815+ format ! ( "{base}\n \n {}" , added. trim_start_matches( '\n' ) )
816+ }
817+
818+ /// How an event's recorded detail reads in a history listing. Everything is its
819+ /// own detail except a `body` event, whose detail is the whole text a body edit
820+ /// replaced (DESIGN.md §8) — bulk kept for recovery, not for reading, so the
821+ /// line says what is there and how to get it back instead of unrolling it.
822+ pub ( crate ) fn event_detail ( event : & Event ) -> String {
823+ let detail = event. detail . clone ( ) . unwrap_or_default ( ) ;
824+ if event. kind != "body" {
825+ return detail;
826+ }
827+ let lines = detail. lines ( ) . count ( ) ;
828+ match event. task_id {
829+ Some ( task_id) => format ! (
830+ "replaced body kept ({lines} lines) — voro show {task_id} --event {}" ,
831+ event. id
832+ ) ,
833+ None => format ! ( "replaced body kept ({lines} lines)" ) ,
834+ }
835+ }
836+
762837/// Free-text positionals (a title, an answer, rejection feedback) arrive as
763838/// the words the shell split them into; join them back and refuse emptiness.
764839fn joined ( words : & [ String ] , what : & str ) -> Result < String , String > {
@@ -1338,9 +1413,10 @@ fn propose_verb(store: &mut Store, args: ProposeArgs) -> Result<String, String>
13381413 Ok ( out)
13391414}
13401415
1341- fn set_verb ( store : & mut Store , args : SetArgs ) -> Result < String , String > {
1416+ fn set_verb ( store : & mut Store , mut args : SetArgs ) -> Result < String , String > {
13421417 let id = args. task_id ;
13431418 let current = store. task ( id) . map_err ( |e| e. to_string ( ) ) ?;
1419+ let body = set_body ( & current. body , & mut args, id) ?;
13441420 let agent = if args. no_agent {
13451421 None
13461422 } else {
@@ -1358,7 +1434,7 @@ fn set_verb(store: &mut Store, args: SetArgs) -> Result<String, String> {
13581434 } ;
13591435 let edit = TaskEdit {
13601436 title : args. title . unwrap_or ( current. title ) ,
1361- body : text_or_file ( args . body , args . body_file ) ? . unwrap_or ( current . body ) ,
1437+ body,
13621438 priority : args. priority . unwrap_or ( current. priority ) ,
13631439 agent,
13641440 human,
@@ -1661,18 +1737,24 @@ fn show_verb(store: &mut Store, id: i64) -> Result<String, String> {
16611737 }
16621738 writeln ! ( out, "\n events:" ) . unwrap ( ) ;
16631739 for e in store. events_for ( id) . map_err ( |e| e. to_string ( ) ) ? {
1664- writeln ! (
1665- out,
1666- " {} {} {}" ,
1667- e. at,
1668- e. kind,
1669- e. detail. unwrap_or_default( )
1670- )
1671- . unwrap ( ) ;
1740+ writeln ! ( out, " {} {} {}" , e. at, e. kind, event_detail( & e) ) . unwrap ( ) ;
16721741 }
16731742 Ok ( out)
16741743}
16751744
1745+ /// One event's recorded detail, alone and undecorated, so it can be redirected
1746+ /// straight into a file: `voro show 62 --event 512 > body.md` is how the body a
1747+ /// `set` replaced comes back (DESIGN.md §8). The event must belong to the task
1748+ /// named, so a mistyped id reads as an error rather than another task's text.
1749+ fn show_event ( store : & mut Store , task_id : i64 , event_id : i64 ) -> Result < String , String > {
1750+ let events = store. events_for ( task_id) . map_err ( |e| e. to_string ( ) ) ?;
1751+ let event = events
1752+ . iter ( )
1753+ . find ( |e| e. id == event_id)
1754+ . ok_or_else ( || format ! ( "task {task_id} has no event {event_id}" ) ) ?;
1755+ Ok ( event. detail . clone ( ) . unwrap_or_default ( ) )
1756+ }
1757+
16761758fn list_verb ( store : & mut Store , args : & ListArgs ) -> Result < String , String > {
16771759 let state_filter = match & args. state {
16781760 Some ( raw) => Some ( TaskState :: parse ( raw) . map_err ( |e| e. to_string ( ) ) ?) ,
@@ -2602,6 +2684,74 @@ mod tests {
26022684 assert ! ( s. refined_flag( 1 ) . unwrap( ) ) ;
26032685 }
26042686
2687+ /// A body replacement is destructive, so the two guards of DESIGN.md §8
2688+ /// hold: one that would leave the body empty is refused outright, and the
2689+ /// text any accepted replacement discards is recoverable from the log.
2690+ #[ test]
2691+ fn emptying_a_body_is_refused_and_a_replaced_one_is_recoverable ( ) {
2692+ let mut s = store ( ) ;
2693+ ok ( & mut s, & [ "project" , "add" , "demo" , "/tmp" ] ) ;
2694+ ok (
2695+ & mut s,
2696+ & [ "add" , "demo" , "An idea" , "--body" , "the brief\n line two" ] ,
2697+ ) ;
2698+
2699+ let e = err ( & mut s, & [ "set" , "1" , "--body" , "" ] ) ;
2700+ assert ! ( e. contains( "--allow-empty" ) , "{e}" ) ;
2701+ assert_eq ! ( s. task( 1 ) . unwrap( ) . body, "the brief\n line two" ) ;
2702+
2703+ ok ( & mut s, & [ "set" , "1" , "--body" , "a rewrite" ] ) ;
2704+ assert_eq ! ( s. task( 1 ) . unwrap( ) . body, "a rewrite" ) ;
2705+
2706+ // The history says a body was replaced and names the event to get it
2707+ // back, rather than unrolling the old text into the listing.
2708+ let out = ok ( & mut s, & [ "show" , "1" ] ) ;
2709+ assert ! ( out. contains( "replaced body kept (2 lines)" ) , "{out}" ) ;
2710+ assert ! ( !out. contains( "line two" ) , "{out}" ) ;
2711+
2712+ let event = out
2713+ . lines ( )
2714+ . find ( |l| l. contains ( "--event" ) )
2715+ . and_then ( |l| l. rsplit ( ' ' ) . next ( ) . map ( str:: to_string) )
2716+ . expect ( "the body event names its own id" ) ;
2717+ assert_eq ! (
2718+ ok( & mut s, & [ "show" , "1" , "--event" , & event] ) ,
2719+ "the brief\n line two"
2720+ ) ;
2721+ assert ! ( err( & mut s, & [ "show" , "1" , "--event" , "999" ] ) . contains( "no event 999" ) ) ;
2722+
2723+ // Emptying it is allowed once said explicitly — and still recoverable.
2724+ ok ( & mut s, & [ "set" , "1" , "--body" , "" , "--allow-empty" ] ) ;
2725+ assert_eq ! ( s. task( 1 ) . unwrap( ) . body, "" ) ;
2726+ }
2727+
2728+ /// `--append-body-file` is the additive spelling for the common "record a
2729+ /// finding on the task" case, which otherwise gets written as a replacement
2730+ /// and takes the brief with it (DESIGN.md §8).
2731+ #[ test]
2732+ fn append_body_adds_to_the_brief_instead_of_replacing_it ( ) {
2733+ let mut s = store ( ) ;
2734+ ok ( & mut s, & [ "project" , "add" , "demo" , "/tmp" ] ) ;
2735+ ok ( & mut s, & [ "add" , "demo" , "An idea" , "--body" , "the brief\n " ] ) ;
2736+
2737+ let path = std:: env:: temp_dir ( ) . join ( format ! ( "voro-append-{}.md" , std:: process:: id( ) ) ) ;
2738+ std:: fs:: write ( & path, "a finding\n " ) . unwrap ( ) ;
2739+ ok (
2740+ & mut s,
2741+ & [ "set" , "1" , "--append-body-file" , path. to_str ( ) . unwrap ( ) ] ,
2742+ ) ;
2743+ std:: fs:: remove_file ( & path) . unwrap ( ) ;
2744+ assert_eq ! ( s. task( 1 ) . unwrap( ) . body, "the brief\n \n a finding\n " ) ;
2745+
2746+ ok ( & mut s, & [ "set" , "1" , "--append-body" , "another" ] ) ;
2747+ assert_eq ! ( s. task( 1 ) . unwrap( ) . body, "the brief\n \n a finding\n \n another" ) ;
2748+
2749+ // Replacement and addition are different intents, not two spellings of
2750+ // one, so asking for both at once is a parse error rather than a race.
2751+ let e = err ( & mut s, & [ "set" , "1" , "--body" , "x" , "--append-body" , "y" ] ) ;
2752+ assert ! ( e. contains( "cannot be used with" ) , "{e}" ) ;
2753+ }
2754+
26052755 /// The inbox renders each row's next-action verb in place of the state,
26062756 /// mirroring the TUI queue — both from `Task::next_action()` (DESIGN.md §3).
26072757 #[ test]
0 commit comments