diff --git a/Cargo.lock b/Cargo.lock index 6e82eff..19fcaef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -2561,6 +2570,7 @@ dependencies = [ "axum", "chrono", "clap", + "colored", "indexmap", "lazy_static", "pulldown-cmark", diff --git a/Cargo.toml b/Cargo.toml index b7f052a..ae00976 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ tracing = "0.1.44" tracing-subscriber = "0.3.22" toml = "0.8.19" indexmap = { version = "2.7.1", features = ["serde"] } +colored = "3.1.1" [build-dependencies] reqwest = { version = "0.13.1", features = ["blocking"] } diff --git a/src/analysis.rs b/src/analysis.rs index 093e232..643bb79 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use colored::*; use pulldown_cmark::{Event, Parser, Tag}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -95,14 +96,14 @@ impl WikiGraph { for (source, targets) in &self.edges { for target in targets { if !self.nodes.contains_key(target) { - println!("\x1b[1m{}\x1b[0m -> ❌ \x1b[31m{}\x1b[0m", source, target); + println!("{} -> ❌ {}", source.bold().cyan(), target.red()); found_issues = true; } } } if !found_issues { - println!("✅ No broken links found!"); + println!("{}", "✅ No broken links found!".green().bold()); } } } diff --git a/src/cli.rs b/src/cli.rs index 4a4a211..52b8699 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,6 +5,8 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(author, version, about = "A simple wiki server/builder")] pub struct Cli { + #[arg(short, long, global = true, default_value = ".")] + pub path: PathBuf, #[command(subcommand)] pub command: Commands, } @@ -13,8 +15,6 @@ pub struct Cli { pub enum Commands { /// Serve the wiki locally Serve { - #[arg(short, long)] - path: PathBuf, #[arg(short, long)] no_navigation: bool, #[arg(short = 'P', long, default_value = "8090")] @@ -24,21 +24,39 @@ pub enum Commands { }, /// Build the static site Build { - #[arg(short, long)] - path: PathBuf, #[arg(short, long)] no_navigation: bool, #[arg(short, long)] out_dir: Option, }, /// Output a DOT graph of the wiki connections - Graph { - #[arg(short, long)] - path: PathBuf, - }, + Graph {}, /// List broken links - Todo { - #[arg(short, long)] - path: PathBuf, + Todo {}, + /// Manage wiki entries + Entry { + #[command(subcommand)] + cmd: EntryCommands, + }, +} + +#[derive(Subcommand)] +pub enum EntryCommands { + /// List all existing entries + List, + /// Create a new entry + New { + /// The title of the new entry (e.g. "The Great Bernardo") + name: String, + }, + /// Remove an entry by its normalized name + Remove { + /// The normalized name of the toml file (e.g. "the-great-bernardo") + name: String, + }, + /// Inspect the TOML config of an entry + Inspect { + /// The normalized name of the toml file (e.g. "the-great-bernardo") + name: String, }, } diff --git a/src/entry.rs b/src/entry.rs new file mode 100644 index 0000000..c78f115 --- /dev/null +++ b/src/entry.rs @@ -0,0 +1,173 @@ +use anyhow::{Context, Result, anyhow}; +use colored::Colorize; +use std::path::PathBuf; +use tokio::fs; + +use crate::WikiConfig; +use crate::cli::EntryCommands; + +pub async fn handle(command: EntryCommands, root_dir: PathBuf) -> Result<()> { + match command { + EntryCommands::List => list_entries(&root_dir).await, + EntryCommands::New { name } => create_entry(&root_dir, &name).await, + EntryCommands::Remove { name } => remove_entry(&root_dir, &name).await, + EntryCommands::Inspect { name } => inspect_entry(&root_dir, &name).await, + } +} + +async fn list_entries(root: &PathBuf) -> Result<()> { + let mut entries = fs::read_dir(root).await?; + let mut rows = Vec::new(); + + // Scan for .toml files + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("toml") { + let name = path.file_stem().unwrap().to_string_lossy().to_string(); + + // Read configuration to check for linked files + let content = fs::read_to_string(&path).await.unwrap_or_default(); + let (md_status, img_status) = if let Ok(config) = toml::from_str::(&content) + { + let md = check_file_status(root, config.content_file); + let img = check_file_status(root, config.image); + (md, img) + } else { + ("Invalid Config".to_string(), "Unknown".to_string()) + }; + + rows.push((name, md_status, img_status)); + } + } + + rows.sort_by(|a, b| a.0.cmp(&b.0)); + + // Print Table + println!( + "{:<25} {:<25} {:<25}", + "Entry Name", "Markdown File", "Image" + ); + + for (name, md, img) in rows { + println!("{:<25} {:<25} {:<25}", name, md, img); + } + + Ok(()) +} + +fn check_file_status(root: &PathBuf, filename: Option) -> String { + match filename { + Some(f) => { + if root.join(&f).exists() { + f.green().to_string() + } else { + format!("{} (Missing)", f.red()) + } + } + None => "None".to_string(), + } +} + +async fn create_entry(root: &PathBuf, title: &str) -> Result<()> { + let slug: String = title + .trim() + .to_lowercase() + .replace([' ', '_'], "-") + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .collect(); + + if slug.is_empty() { + return Err(anyhow!("Resulting entry name is empty")); + } + + let toml_filename = format!("{}.toml", slug); + let md_filename = format!("{}.md", slug); + let img_filename = format!("{}.png", slug); + + let toml_path = root.join(&toml_filename); + let md_path = root.join(&md_filename); + + if toml_path.exists() { + return Err(anyhow!("Entry '{}' already exists", slug)); + } + + // Minimal default content + let toml_content = format!( + r#"title = "{}" +image = "{}" +content_file = "{}" + +[infobox] +"Type" = "Draft" +"Status" = "WIP" +"Created" = "2026" +"Category" = "General""#, + title, img_filename, md_filename + ); + + fs::write(&toml_path, toml_content) + .await + .context("Failed to write TOML")?; + + // Markdown Placeholder + let md_content = format!("Auto-generated markdown file for \"{}\"...", title); + fs::write(&md_path, md_content) + .await + .context("Failed to write Markdown")?; + + println!("Created entry '{}'", title); + println!(" - TOML: {:?}", toml_path); + println!(" - Markdown: {:?}", md_path); + println!(" - (Expected image: {:?})", img_filename); + + Ok(()) +} + +async fn remove_entry(root: &PathBuf, name: &str) -> Result<()> { + let toml_path = root.join(format!("{}.toml", name)); + + if !toml_path.exists() { + return Err(anyhow!( + "Entry '{}' not found (looked at {:?})", + name, + toml_path + )); + } + + let content = fs::read_to_string(&toml_path).await?; + let config: WikiConfig = toml::from_str(&content).context("Failed to parse TOML")?; + + // Try to remove linked files + if let Some(md_file) = config.content_file { + let p = root.join(&md_file); + if p.exists() { + fs::remove_file(&p).await?; + println!("Removed: {}", md_file); + } + } + + if let Some(img_file) = config.image { + let p = root.join(&img_file); + if p.exists() { + fs::remove_file(&p).await?; + println!("Removed: {}", img_file); + } + } + + fs::remove_file(&toml_path).await?; + println!("Removed: {}.toml", name); + + Ok(()) +} + +async fn inspect_entry(root: &PathBuf, name: &str) -> Result<()> { + let toml_path = root.join(format!("{}.toml", name)); + if !toml_path.exists() { + return Err(anyhow!("Entry '{}' not found", name)); + } + + let content = fs::read_to_string(&toml_path).await?; + println!("{}", content); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 2505912..dabea1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use tera::{Context, Tera}; mod analysis; mod cli; mod codeblocks; +mod entry; mod rendering; use crate::{ @@ -81,14 +82,15 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); + // Resolve the global path argument once + let abs_path = std::fs::canonicalize(&cli.path)?; + match cli.command { Commands::Serve { - path, port, host, no_navigation, } => { - let abs_path = std::fs::canonicalize(&path)?; let shared_state = Arc::new(AppState { docs_dir: abs_path, no_navigation, @@ -113,25 +115,24 @@ async fn main() -> anyhow::Result<()> { axum::serve(listener, app).await?; } Commands::Build { - path, no_navigation, out_dir, } => { - let abs_path = std::fs::canonicalize(&path)?; let output_path = out_dir.unwrap_or_else(|| abs_path.clone()); tokio::fs::create_dir_all(&output_path).await?; run_build(abs_path, output_path, no_navigation).await?; } - Commands::Graph { path } => { - let abs_path = std::fs::canonicalize(&path)?; + Commands::Graph {} => { let graph = analysis::WikiGraph::new(abs_path).await?; graph.print_dot(); } - Commands::Todo { path } => { - let abs_path = std::fs::canonicalize(&path)?; + Commands::Todo {} => { let graph = analysis::WikiGraph::new(abs_path).await?; graph.check_dead_links(); } + Commands::Entry { cmd } => { + entry::handle(cmd, abs_path).await?; + } } Ok(()) }