@@ -41,6 +41,29 @@ pub struct UpdateMetadataInput {
4141 pub modified_by : String ,
4242}
4343
44+ #[ derive( Debug , Clone ) ]
45+ pub enum EditMode {
46+ Replace ,
47+ Prepend ,
48+ Append ,
49+ }
50+
51+ #[ derive( Debug , Clone ) ]
52+ pub struct EditInput {
53+ pub file : String ,
54+ pub heading : String ,
55+ pub content : String ,
56+ pub mode : EditMode ,
57+ pub modified_by : String ,
58+ }
59+
60+ #[ derive( Debug , Clone , serde:: Serialize ) ]
61+ pub struct EditResult {
62+ pub path : String ,
63+ pub heading : String ,
64+ pub mode : String ,
65+ }
66+
4467#[ derive( Debug , Clone , serde:: Serialize ) ]
4568pub struct WriteResult {
4669 pub path : String ,
@@ -694,6 +717,91 @@ pub fn update_metadata(
694717 } )
695718}
696719
720+ /// Edit a specific section within an existing note.
721+ ///
722+ /// Finds the target section by heading name, then applies the edit based on mode:
723+ /// - Replace: replace the entire section body with new content
724+ /// - Append: add new content at the end of the section body
725+ /// - Prepend: add new content at the start of the section body
726+ ///
727+ /// Does NOT re-index chunks — that's for the MCP layer.
728+ pub fn edit_note (
729+ store : & Store ,
730+ vault_path : & Path ,
731+ input : & EditInput ,
732+ _obsidian : Option < & mut crate :: obsidian:: ObsidianCli > ,
733+ ) -> Result < EditResult > {
734+ // Step 1: Resolve file via store
735+ let file_record = store
736+ . resolve_file ( & input. file ) ?
737+ . ok_or_else ( || anyhow:: anyhow!( "file not found: {}" , input. file) ) ?;
738+
739+ let full_path = vault_path. join ( & file_record. path ) ;
740+
741+ // Step 2: Read current content from disk
742+ let content = std:: fs:: read_to_string ( & full_path) ?;
743+
744+ // Step 3: Find the target section
745+ let section = crate :: markdown:: find_section ( & content, & input. heading )
746+ . ok_or_else ( || anyhow:: anyhow!( "section '{}' not found in {}" , input. heading, input. file) ) ?;
747+
748+ // Step 4: Apply the edit based on mode
749+ let lines: Vec < & str > = content. lines ( ) . collect ( ) ;
750+ let before = & lines[ ..section. body_start ] ;
751+ let body = & lines[ section. body_start ..section. body_end ] ;
752+ let after = & lines[ section. body_end ..] ;
753+
754+ let mode_name;
755+ let new_body = match input. mode {
756+ EditMode :: Replace => {
757+ mode_name = "Replace" ;
758+ format ! ( "\n {}\n " , input. content. trim_end( ) )
759+ }
760+ EditMode :: Append => {
761+ mode_name = "Append" ;
762+ let existing = body. join ( "\n " ) ;
763+ let trimmed_existing = existing. trim_end ( ) ;
764+ if trimmed_existing. is_empty ( ) {
765+ format ! ( "\n {}\n " , input. content. trim_end( ) )
766+ } else {
767+ format ! ( "{}\n {}\n " , trimmed_existing, input. content. trim_end( ) )
768+ }
769+ }
770+ EditMode :: Prepend => {
771+ mode_name = "Prepend" ;
772+ let existing = body. join ( "\n " ) ;
773+ let trimmed_existing = existing. trim_start ( ) ;
774+ if trimmed_existing. is_empty ( ) {
775+ format ! ( "\n {}\n " , input. content. trim_end( ) )
776+ } else {
777+ format ! ( "\n {}\n {}" , input. content. trim_end( ) , trimmed_existing)
778+ }
779+ }
780+ } ;
781+
782+ // Step 5: Reconstruct the file
783+ let mut result_parts: Vec < String > = Vec :: new ( ) ;
784+ if !before. is_empty ( ) {
785+ result_parts. push ( before. join ( "\n " ) ) ;
786+ }
787+ result_parts. push ( new_body) ;
788+ if !after. is_empty ( ) {
789+ result_parts. push ( after. join ( "\n " ) ) ;
790+ }
791+ // Join with newlines, ensuring we don't double up
792+ let new_content = result_parts. join ( "\n " ) ;
793+
794+ // Step 6: Write atomically (overwrite = true)
795+ atomic_write ( & full_path, & new_content, true ) ?;
796+
797+ // Step 7: Return EditResult
798+ Ok ( EditResult {
799+ path : file_record. path ,
800+ heading : input. heading . clone ( ) ,
801+ mode : mode_name. to_string ( ) ,
802+ } )
803+ }
804+
697805/// Move a note to a new folder.
698806pub fn move_note (
699807 file : & str ,
@@ -1187,4 +1295,128 @@ mod tests {
11871295 assert_ne ! ( h1, h3) ;
11881296 assert_eq ! ( h1. len( ) , 64 ) ; // SHA-256 hex
11891297 }
1298+
1299+ fn setup_vault ( ) -> ( tempfile:: TempDir , Store , std:: path:: PathBuf ) {
1300+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
1301+ let store = Store :: open_memory ( ) . unwrap ( ) ;
1302+ let root = tmp. path ( ) . to_path_buf ( ) ;
1303+ ( tmp, store, root)
1304+ }
1305+
1306+ #[ test]
1307+ fn test_edit_note_append_to_section ( ) {
1308+ let ( _tmp, store, root) = setup_vault ( ) ;
1309+ let content = "# Person\n \n ## Interactions\n \n Old entry\n \n ## Links\n \n Some links\n " ;
1310+ std:: fs:: write ( root. join ( "person.md" ) , content) . unwrap ( ) ;
1311+ store
1312+ . insert_file ( "person.md" , "hash" , 100 , & [ ] , "per123" , None )
1313+ . unwrap ( ) ;
1314+
1315+ let input = EditInput {
1316+ file : "person.md" . into ( ) ,
1317+ heading : "Interactions" . into ( ) ,
1318+ content : "New entry" . into ( ) ,
1319+ mode : EditMode :: Append ,
1320+ modified_by : "test" . into ( ) ,
1321+ } ;
1322+ let result = edit_note ( & store, & root, & input, None ) . unwrap ( ) ;
1323+ assert_eq ! ( result. heading, "Interactions" ) ;
1324+ assert_eq ! ( result. mode, "Append" ) ;
1325+
1326+ let updated = std:: fs:: read_to_string ( root. join ( "person.md" ) ) . unwrap ( ) ;
1327+ assert ! ( updated. contains( "Old entry" ) ) ;
1328+ assert ! ( updated. contains( "New entry" ) ) ;
1329+ // New entry should be before ## Links
1330+ let new_pos = updated. find ( "New entry" ) . unwrap ( ) ;
1331+ let links_pos = updated. find ( "## Links" ) . unwrap ( ) ;
1332+ assert ! ( new_pos < links_pos) ;
1333+ }
1334+
1335+ #[ test]
1336+ fn test_edit_note_replace_section ( ) {
1337+ let ( _tmp, store, root) = setup_vault ( ) ;
1338+ let content = "# Note\n \n ## Tasks\n \n - [x] Old task\n \n ## Notes\n \n Text\n " ;
1339+ std:: fs:: write ( root. join ( "note.md" ) , content) . unwrap ( ) ;
1340+ store
1341+ . insert_file ( "note.md" , "hash" , 100 , & [ ] , "not123" , None )
1342+ . unwrap ( ) ;
1343+
1344+ let input = EditInput {
1345+ file : "note.md" . into ( ) ,
1346+ heading : "Tasks" . into ( ) ,
1347+ content : "- [ ] New task\n " . into ( ) ,
1348+ mode : EditMode :: Replace ,
1349+ modified_by : "test" . into ( ) ,
1350+ } ;
1351+ edit_note ( & store, & root, & input, None ) . unwrap ( ) ;
1352+
1353+ let updated = std:: fs:: read_to_string ( root. join ( "note.md" ) ) . unwrap ( ) ;
1354+ assert ! ( !updated. contains( "Old task" ) ) ;
1355+ assert ! ( updated. contains( "New task" ) ) ;
1356+ assert ! ( updated. contains( "## Notes" ) ) ; // Other sections untouched
1357+ }
1358+
1359+ #[ test]
1360+ fn test_edit_note_prepend_to_section ( ) {
1361+ let ( _tmp, store, root) = setup_vault ( ) ;
1362+ let content = "# Doc\n \n ## Log\n \n Existing line\n \n ## Footer\n \n End\n " ;
1363+ std:: fs:: write ( root. join ( "doc.md" ) , content) . unwrap ( ) ;
1364+ store
1365+ . insert_file ( "doc.md" , "hash" , 100 , & [ ] , "doc123" , None )
1366+ . unwrap ( ) ;
1367+
1368+ let input = EditInput {
1369+ file : "doc.md" . into ( ) ,
1370+ heading : "Log" . into ( ) ,
1371+ content : "Prepended line" . into ( ) ,
1372+ mode : EditMode :: Prepend ,
1373+ modified_by : "test" . into ( ) ,
1374+ } ;
1375+ edit_note ( & store, & root, & input, None ) . unwrap ( ) ;
1376+
1377+ let updated = std:: fs:: read_to_string ( root. join ( "doc.md" ) ) . unwrap ( ) ;
1378+ assert ! ( updated. contains( "Prepended line" ) ) ;
1379+ assert ! ( updated. contains( "Existing line" ) ) ;
1380+ // Prepended should come before existing
1381+ let prepend_pos = updated. find ( "Prepended line" ) . unwrap ( ) ;
1382+ let existing_pos = updated. find ( "Existing line" ) . unwrap ( ) ;
1383+ assert ! ( prepend_pos < existing_pos) ;
1384+ }
1385+
1386+ #[ test]
1387+ fn test_edit_note_section_not_found ( ) {
1388+ let ( _tmp, store, root) = setup_vault ( ) ;
1389+ let content = "# Note\n \n ## Existing\n \n Content\n " ;
1390+ std:: fs:: write ( root. join ( "note.md" ) , content) . unwrap ( ) ;
1391+ store
1392+ . insert_file ( "note.md" , "hash" , 100 , & [ ] , "not123" , None )
1393+ . unwrap ( ) ;
1394+
1395+ let input = EditInput {
1396+ file : "note.md" . into ( ) ,
1397+ heading : "Missing" . into ( ) ,
1398+ content : "Stuff" . into ( ) ,
1399+ mode : EditMode :: Append ,
1400+ modified_by : "test" . into ( ) ,
1401+ } ;
1402+ let result = edit_note ( & store, & root, & input, None ) ;
1403+ assert ! ( result. is_err( ) ) ;
1404+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "section 'Missing' not found" ) ) ;
1405+ }
1406+
1407+ #[ test]
1408+ fn test_edit_note_file_not_found ( ) {
1409+ let ( _tmp, store, root) = setup_vault ( ) ;
1410+
1411+ let input = EditInput {
1412+ file : "nonexistent.md" . into ( ) ,
1413+ heading : "Section" . into ( ) ,
1414+ content : "Stuff" . into ( ) ,
1415+ mode : EditMode :: Append ,
1416+ modified_by : "test" . into ( ) ,
1417+ } ;
1418+ let result = edit_note ( & store, & root, & input, None ) ;
1419+ assert ! ( result. is_err( ) ) ;
1420+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "file not found" ) ) ;
1421+ }
11901422}
0 commit comments