Skip to content

Commit 606b7aa

Browse files
committed
feat(writer): add edit_note with section targeting
1 parent 51ee332 commit 606b7aa

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

src/writer.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
4568
pub 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.
698806
pub 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\nOld entry\n\n## Links\n\nSome 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\nText\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\nExisting line\n\n## Footer\n\nEnd\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\nContent\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

Comments
 (0)