Skip to main content

patchloom/api/
md.rs

1//! Markdown section-aware operations for the public library API.
2//!
3//! Standard write functions delegate to the tx engine via `execute_as_edit_result`.
4//! Special cases (md_move_section cross-file, md_dedupe_headings with extra return
5//! data) keep direct implementations with feature-gated tx fallbacks.
6
7use std::path::Path;
8
9use crate::containment::PathGuard;
10use crate::ops;
11use crate::plan::Operation;
12
13use super::{ApplyMode, EditResult};
14
15/// Unified write path for standard md operations.
16#[cfg(any(feature = "cli", feature = "files"))]
17fn md_write(
18    op: Operation,
19    path: &Path,
20    mode: ApplyMode,
21    guard: Option<&PathGuard>,
22    action: &'static str,
23) -> anyhow::Result<EditResult> {
24    let abs = super::library_abs_path(path, guard)?;
25    let mut op = op;
26    rewrite_md_op_path(&mut op, &super::library_op_path(path, &abs, guard));
27    let display = path.to_string_lossy();
28    super::execute_as_edit_result_with_path(
29        op,
30        mode,
31        super::library_project_root(&abs, guard),
32        guard,
33        action,
34        None,
35        Some(display.as_ref()),
36    )
37}
38
39#[cfg(any(feature = "cli", feature = "files"))]
40fn rewrite_md_op_path(op: &mut Operation, dest: &str) {
41    match op {
42        Operation::MdReplaceSection { path, .. }
43        | Operation::MdInsertAfterHeading { path, .. }
44        | Operation::MdInsertAfterSection { path, .. }
45        | Operation::MdInsertBeforeHeading { path, .. }
46        | Operation::MdUpsertBullet { path, .. }
47        | Operation::MdTableAppend { path, .. }
48        | Operation::MdMoveSection { path, .. }
49        | Operation::MdDedupeHeadings { path, .. }
50        | Operation::MdLintAgents { path, .. } => {
51            *path = dest.into();
52        }
53        _ => {}
54    }
55}
56
57#[cfg(not(any(feature = "cli", feature = "files")))]
58fn md_write(
59    _op: Operation,
60    path: &Path,
61    mode: ApplyMode,
62    guard: Option<&PathGuard>,
63    action: &'static str,
64) -> anyhow::Result<EditResult> {
65    // Fallback: execute the md operation through the tx engine's execute
66    // path. Without the tx module, delegate to the ops layer directly.
67    use crate::write::WritePolicy;
68
69    // Re-extract the operation fields to call ops directly.
70    // This is only used when building without cli/files features.
71    let display = path.to_string_lossy();
72    let path_owned = super::library_abs_path(path, guard)?;
73    let path = path_owned.as_path();
74    let path_str = display;
75    let original = crate::files::load_text_strict(path, &path_str)?;
76
77    let new_content = match _op {
78        Operation::MdReplaceSection {
79            heading, content, ..
80        } => ops::md::replace_section_in(&original, &heading, &content)
81            .map_err(|e| e.into_anyhow(&heading))?,
82        Operation::MdInsertAfterHeading {
83            heading, content, ..
84        } => ops::md::insert_after_heading_in(&original, &heading, &content)
85            .map_err(|e| e.into_anyhow(&heading))?,
86        Operation::MdInsertAfterSection {
87            heading, content, ..
88        } => ops::md::insert_after_section_in(&original, &heading, &content)
89            .map_err(|e| e.into_anyhow(&heading))?,
90        Operation::MdInsertBeforeHeading {
91            heading, content, ..
92        } => ops::md::insert_before_heading_in(&original, &heading, &content)
93            .map_err(|e| e.into_anyhow(&heading))?,
94        Operation::MdUpsertBullet {
95            heading, bullet, ..
96        } => ops::md::upsert_bullet_in(&original, &heading, &bullet)
97            .map_err(|e| e.into_anyhow(&heading))?,
98        Operation::MdTableAppend { heading, row, .. } => {
99            let (body_start, body_end) =
100                ops::md::find_section(&original, &heading).map_err(|e| e.into_anyhow(&heading))?;
101            return match ops::md::table_append_in(&original, body_start, body_end, &row) {
102                Ok(new_content) => {
103                    let policy = WritePolicy::default();
104                    let (applied, backup_session) =
105                        super::write_if_apply(path, &new_content, mode, &policy, guard)?;
106                    let mut edit = super::build_edit_result(
107                        &path_str,
108                        original,
109                        new_content,
110                        applied,
111                        action,
112                        None,
113                    );
114                    edit.backup_session = backup_session;
115                    Ok(edit)
116                }
117                Err(e) => Err(anyhow::Error::new(crate::exit::InvalidInputError {
118                    msg: format!("{e} under heading {heading:?} in {path_str}"),
119                })),
120            };
121        }
122        _ => {
123            return Err(anyhow::Error::new(crate::exit::InvalidInputError {
124                msg: format!("unsupported md operation in no-cli path for {path_str}"),
125            }));
126        }
127    };
128
129    let policy = WritePolicy::default();
130    let (applied, backup_session) =
131        super::write_if_apply(path, &new_content, mode, &policy, guard)?;
132    let mut edit =
133        super::build_edit_result(&path_str, original, new_content, applied, action, None);
134    edit.backup_session = backup_session;
135    Ok(edit)
136}
137
138/// Replace the body of a markdown section identified by heading.
139pub fn md_replace_section(
140    path: &Path,
141    heading: &str,
142    content: &str,
143    mode: ApplyMode,
144    guard: Option<&PathGuard>,
145) -> anyhow::Result<EditResult> {
146    let op = Operation::MdReplaceSection {
147        path: path.to_string_lossy().into(),
148        heading: heading.into(),
149        content: content.into(),
150    };
151    md_write(op, path, mode, guard, "md.replace_section")
152}
153
154/// Insert or update a bullet point under a markdown heading.
155///
156/// If the bullet already exists (exact match), the file is unchanged.
157pub fn md_upsert_bullet(
158    path: &Path,
159    heading: &str,
160    bullet: &str,
161    mode: ApplyMode,
162    guard: Option<&PathGuard>,
163) -> anyhow::Result<EditResult> {
164    let op = Operation::MdUpsertBullet {
165        path: path.to_string_lossy().into(),
166        heading: heading.into(),
167        bullet: bullet.into(),
168    };
169    md_write(op, path, mode, guard, "md.upsert_bullet")
170}
171
172/// Append a row to a markdown table under a heading.
173pub fn md_table_append(
174    path: &Path,
175    heading: &str,
176    row: &str,
177    mode: ApplyMode,
178    guard: Option<&PathGuard>,
179) -> anyhow::Result<EditResult> {
180    let op = Operation::MdTableAppend {
181        path: path.to_string_lossy().into(),
182        heading: heading.into(),
183        row: row.into(),
184    };
185    md_write(op, path, mode, guard, "md.table_append")
186}
187
188/// Insert content after a markdown heading.
189pub fn md_insert_after_heading(
190    path: &Path,
191    heading: &str,
192    insertion: &str,
193    mode: ApplyMode,
194    guard: Option<&PathGuard>,
195) -> anyhow::Result<EditResult> {
196    let op = Operation::MdInsertAfterHeading {
197        path: path.to_string_lossy().into(),
198        heading: heading.into(),
199        content: insertion.into(),
200    };
201    md_write(op, path, mode, guard, "md.insert_after_heading")
202}
203
204/// Insert content after the full section body (sibling placement). #1726
205pub fn md_insert_after_section(
206    path: &Path,
207    heading: &str,
208    insertion: &str,
209    mode: ApplyMode,
210    guard: Option<&PathGuard>,
211) -> anyhow::Result<EditResult> {
212    let op = Operation::MdInsertAfterSection {
213        path: path.to_string_lossy().into(),
214        heading: heading.into(),
215        content: insertion.into(),
216    };
217    md_write(op, path, mode, guard, "md.insert_after_section")
218}
219
220/// Move a markdown section to a position relative to another heading.
221///
222/// For same-file moves, pass `to` as `None`. For cross-file moves, pass the
223/// destination file path in `to`.
224///
225/// Cross-file moves are complex (two files, guard on dest) and retain the
226/// direct implementation. Same-file moves route through the tx engine when
227/// available.
228pub fn md_move_section(
229    path: &Path,
230    heading: &str,
231    position: (&str, &str),
232    to: Option<&Path>,
233    mode: ApplyMode,
234    guard: Option<&PathGuard>,
235) -> anyhow::Result<EditResult> {
236    // Cross-file moves retain the direct implementation because the tx engine
237    // only handles single-file operations. Same-file moves can route through
238    // the engine but cross-file needs coordinated writes to two files.
239    let display = path.to_string_lossy().into_owned();
240    let path_owned = super::library_abs_path(path, guard)?;
241    let dest_owned = match to {
242        Some(dest_path) => Some(super::library_abs_path(dest_path, guard)?),
243        None => None,
244    };
245    let path = path_owned.as_path();
246    let to = dest_owned.as_deref();
247    let path_str = display;
248    let original = crate::files::load_text_strict(path, &path_str)?;
249
250    let dest_content = match to {
251        Some(dest_path) => {
252            let d = dest_path.to_string_lossy();
253            crate::files::load_text_strict(dest_path, &d)?
254        }
255        None => original.clone(),
256    };
257
258    let (new_source, new_dest) =
259        ops::md::move_section_in(&original, heading, &dest_content, position, to.is_none())
260            .map_err(|e| e.into_anyhow(heading, position.1))?;
261
262    let policy = crate::write::WritePolicy::default();
263    let dest = to.map(|p| p.to_string_lossy().to_string());
264
265    // Cross-file: one backup session covering source + dest (all-or-nothing).
266    // Same-file: single write path.
267    let (applied, backup_session) = if let Some(dest_path) = to {
268        let backup_root = super::library_project_root(path, guard);
269        let files: [(&Path, &str); 2] =
270            [(dest_path, new_dest.as_str()), (path, new_source.as_str())];
271        super::write_if_apply_many(&files, mode, &policy, guard, backup_root)?
272    } else {
273        super::write_if_apply(path, &new_source, mode, &policy, guard)?
274    };
275
276    let mut edit =
277        super::build_edit_result(&path_str, original, new_source, applied, "md.move", dest);
278    edit.backup_session = backup_session;
279    Ok(edit)
280}
281
282/// Remove duplicate headings at the same level in a markdown file.
283///
284/// Returns the `EditResult` and a list of removed duplicate heading texts.
285/// Retains direct implementation because the removed-headings list is not
286/// available from the tx engine output.
287pub fn md_dedupe_headings(
288    path: &Path,
289    mode: ApplyMode,
290    guard: Option<&PathGuard>,
291) -> anyhow::Result<(EditResult, Vec<String>)> {
292    let display = path.to_string_lossy();
293    let path_owned = super::library_abs_path(path, guard)?;
294    let path = path_owned.as_path();
295    let path_str = display;
296    let original = crate::files::load_text_strict(path, &path_str)?;
297
298    let (new_content, removed) = ops::md::dedupe_headings_in(&original);
299
300    let policy = crate::write::WritePolicy::default();
301    let (applied, backup_session) =
302        super::write_if_apply(path, &new_content, mode, &policy, guard)?;
303    let mut edit =
304        super::build_edit_result(&path_str, original, new_content, applied, "md.dedupe", None);
305    edit.backup_session = backup_session;
306    Ok((edit, removed))
307}
308
309/// A lint issue found in a markdown file.
310///
311/// Re-exported from `ops::md` so library consumers don't need to import
312/// from the internal `cmd` module path.
313pub use crate::ops::md::LintIssue;
314
315/// Lint a markdown file for common agent-rules issues (duplicate headings,
316/// missing sections, etc.).
317///
318/// Returns a list of lint issues found. This is a read-only operation.
319pub fn md_lint_agents(path: &Path) -> anyhow::Result<Vec<LintIssue>> {
320    let display = path.to_string_lossy();
321    let content = crate::files::load_text_strict(path, &display)?;
322    Ok(crate::ops::md::lint_agents_content(&content))
323}
324
325/// Insert content before a markdown heading.
326pub fn md_insert_before_heading(
327    path: &Path,
328    heading: &str,
329    insertion: &str,
330    mode: ApplyMode,
331    guard: Option<&PathGuard>,
332) -> anyhow::Result<EditResult> {
333    let op = Operation::MdInsertBeforeHeading {
334        path: path.to_string_lossy().into(),
335        heading: heading.into(),
336        content: insertion.into(),
337    };
338    md_write(op, path, mode, guard, "md.insert_before_heading")
339}