Skip to main content

llmwiki_tooling/
lib.rs

1mod cmd;
2mod config;
3mod edit_plan;
4mod error;
5mod frontmatter;
6mod inventory;
7mod link_index;
8mod markdown_document;
9mod markdown_links;
10mod mention;
11mod page;
12mod splice;
13mod wiki;
14
15use std::path::PathBuf;
16use std::process::ExitCode;
17
18use clap::{Parser, Subcommand, ValueEnum};
19
20use crate::cmd::lint::SeverityFilter;
21use crate::config::WikiConfig;
22use crate::error::WikiError;
23use crate::wiki::{Wiki, WikiRoot};
24
25#[derive(Parser)]
26#[command(
27    name = "llmwiki-tool",
28    about = "Manage LLM wiki knowledge bases",
29    version
30)]
31struct Cli {
32    /// Wiki root directory (auto-detected from CWD if omitted)
33    #[arg(long, global = true)]
34    root: Option<PathBuf>,
35
36    #[command(subcommand)]
37    command: Command,
38}
39
40#[derive(Subcommand)]
41enum Command {
42    /// Wikilink operations
43    Links {
44        #[command(subcommand)]
45        action: LinksAction,
46    },
47    /// Run all checks (structural + rules)
48    Lint {
49        /// Filter by severity level
50        #[arg(long, value_enum, default_value_t = SeverityArg::All)]
51        severity: SeverityArg,
52    },
53    /// Rename a page with full reference update
54    Rename {
55        /// Current page name (without .md)
56        old: String,
57        /// New page name (without .md)
58        new: String,
59        /// Apply changes (default: dry-run)
60        #[arg(long)]
61        write: bool,
62    },
63    /// Move a page to another directory and rebase relative markdown links
64    Move {
65        /// Page name (without .md)
66        page: String,
67        /// Destination directory, relative to wiki root
68        dest_dir: PathBuf,
69        /// Apply changes (default: dry-run)
70        #[arg(long)]
71        write: bool,
72    },
73    /// Query the link graph
74    Refs {
75        #[command(subcommand)]
76        action: RefsAction,
77    },
78    /// Section heading operations
79    Sections {
80        #[command(subcommand)]
81        action: SectionsAction,
82    },
83    /// Frontmatter operations
84    Frontmatter {
85        #[command(subcommand)]
86        action: FrontmatterAction,
87    },
88    /// Scan wiki structure and output per-directory statistics
89    Scan,
90    /// Setup and configuration utilities
91    Setup {
92        #[command(subcommand)]
93        action: SetupAction,
94    },
95}
96
97#[derive(Subcommand)]
98enum SetupAction {
99    /// Output setup workflow prompt for an LLM agent
100    Prompt,
101    /// Output a complete annotated wiki.toml with all options
102    ExampleConfig,
103    /// Generate a minimal wiki.toml from detected structure
104    Init {
105        /// Print to stdout instead of writing wiki.toml
106        #[arg(long)]
107        show: bool,
108        /// Overwrite existing wiki.toml
109        #[arg(long, short)]
110        force: bool,
111    },
112}
113
114#[derive(Clone, Copy, ValueEnum)]
115enum SeverityArg {
116    All,
117    Error,
118    Warn,
119}
120
121impl From<SeverityArg> for SeverityFilter {
122    fn from(arg: SeverityArg) -> Self {
123        match arg {
124            SeverityArg::All => Self::All,
125            SeverityArg::Error => Self::ErrorOnly,
126            SeverityArg::Warn => Self::WarnOnly,
127        }
128    }
129}
130
131#[derive(Subcommand)]
132enum LinksAction {
133    /// Find bare mentions that should be wikilinks
134    Check,
135    /// Auto-link bare mentions
136    Fix {
137        /// Apply changes (default: dry-run showing diff)
138        #[arg(long)]
139        write: bool,
140    },
141    /// Find wikilinks pointing to non-existent pages/headings/blocks
142    Broken,
143    /// Find pages with no inbound wikilinks
144    Orphans,
145}
146
147#[derive(Subcommand)]
148enum RefsAction {
149    /// Pages that link to the given page
150    To { page: String },
151    /// Pages the given page links to
152    From { page: String },
153    /// Full link graph
154    Graph,
155}
156
157#[derive(Subcommand)]
158enum FrontmatterAction {
159    /// Extract frontmatter (JSON output)
160    Get {
161        file: PathBuf,
162        /// Specific field to extract
163        field: Option<String>,
164    },
165    /// Modify a frontmatter field
166    Set {
167        file: PathBuf,
168        field: String,
169        value: String,
170    },
171}
172
173#[derive(Subcommand)]
174enum SectionsAction {
175    /// Rename a heading across the wiki, including [[page#heading]] references
176    Rename {
177        /// Current heading text
178        old: String,
179        /// New heading text
180        new: String,
181        /// Only rename in these directories (path prefix)
182        #[arg(long)]
183        dirs: Option<Vec<String>>,
184        /// Apply changes (default: dry-run)
185        #[arg(long)]
186        write: bool,
187    },
188}
189
190fn resolve_root(cli_root: Option<PathBuf>) -> Result<WikiRoot, WikiError> {
191    match cli_root {
192        Some(path) => WikiRoot::from_path(path),
193        None => {
194            let cwd = std::env::current_dir().map_err(|_| WikiError::RootNotFound {
195                start: PathBuf::from("."),
196            })?;
197            WikiRoot::discover(&cwd)
198        }
199    }
200}
201
202fn run_inner() -> Result<ExitCode, anyhow::Error> {
203    let cli = Cli::parse();
204    let root = resolve_root(cli.root)?;
205
206    // Commands that don't need config/catalog
207    match &cli.command {
208        Command::Scan => {
209            let config = WikiConfig::load_or_detect(root.path())?;
210            crate::cmd::agent::scan(&root, &config.ignore)?;
211            return Ok(ExitCode::SUCCESS);
212        }
213        Command::Setup { action } => {
214            match action {
215                SetupAction::Prompt => crate::cmd::agent::setup(&root)?,
216                SetupAction::ExampleConfig => crate::cmd::agent::example_config(),
217                SetupAction::Init { force, show } => crate::cmd::init::init(&root, *force, *show)?,
218            }
219            return Ok(ExitCode::SUCCESS);
220        }
221        _ => {}
222    }
223
224    // Commands that need config and wiki
225    let config = WikiConfig::load_or_detect(root.path())?;
226    let mut wiki = Wiki::build(root, config)?;
227
228    match cli.command {
229        Command::Links { action } => match action {
230            LinksAction::Check => {
231                let count = crate::cmd::links::check(&wiki)?;
232                if count > 0 {
233                    eprintln!("{count} bare mention(s) found");
234                }
235            }
236            LinksAction::Fix { write } => {
237                let count = crate::cmd::links::fix(&mut wiki, write)?;
238                if count > 0 && !write {
239                    eprintln!("{count} bare mention(s) to fix. Use --write to apply.");
240                } else if count == 0 {
241                    eprintln!("no bare mentions found");
242                }
243            }
244            LinksAction::Broken => {
245                let count = crate::cmd::links::broken(&wiki)?;
246                if count > 0 {
247                    eprintln!("{count} broken link(s) found");
248                    return Ok(ExitCode::from(1));
249                }
250            }
251            LinksAction::Orphans => {
252                let count = crate::cmd::links::orphans(&wiki)?;
253                if count > 0 {
254                    eprintln!("{count} orphan page(s) found");
255                }
256            }
257        },
258
259        Command::Lint { severity } => {
260            let errors = crate::cmd::lint::lint(&wiki, severity.into())?;
261            if errors > 0 {
262                return Ok(ExitCode::from(2));
263            }
264        }
265
266        Command::Rename { old, new, write } => {
267            crate::cmd::rename::rename(&mut wiki, &old, &new, write)?;
268        }
269
270        Command::Move {
271            page,
272            dest_dir,
273            write,
274        } => {
275            crate::cmd::move_page::move_page(&mut wiki, &page, &dest_dir, write)?;
276        }
277
278        Command::Refs { action } => match action {
279            RefsAction::To { page } => {
280                crate::cmd::refs::refs_to(&wiki, &page)?;
281            }
282            RefsAction::From { page } => {
283                crate::cmd::refs::refs_from(&wiki, &page)?;
284            }
285            RefsAction::Graph => {
286                crate::cmd::refs::refs_graph(&wiki)?;
287            }
288        },
289
290        Command::Sections { action } => match action {
291            SectionsAction::Rename {
292                old,
293                new,
294                dirs,
295                write,
296            } => {
297                let count = crate::cmd::sections::rename(&mut wiki, &old, &new, &dirs, write)?;
298                if count > 0 && !write {
299                    eprintln!("{count} occurrence(s) to rename. Use --write to apply.");
300                } else if count == 0 {
301                    eprintln!("no occurrences of '{}' found", old);
302                }
303            }
304        },
305
306        Command::Frontmatter { action } => match action {
307            FrontmatterAction::Get { file, field } => {
308                crate::cmd::frontmatter_cmd::get(&wiki, &file, field.as_deref())?;
309            }
310            FrontmatterAction::Set { file, field, value } => {
311                crate::cmd::frontmatter_cmd::set(&mut wiki, &file, &field, &value)?;
312            }
313        },
314
315        // Handled in the early match above
316        Command::Scan | Command::Setup { .. } => unreachable!(),
317    }
318
319    Ok(ExitCode::SUCCESS)
320}
321
322pub fn run() -> ExitCode {
323    match run_inner() {
324        Ok(code) => code,
325        Err(e) => {
326            eprintln!("error: {e:#}");
327            ExitCode::FAILURE
328        }
329    }
330}