From 2d62a179b972d009dd5fe26a4f6fa5913e2289d8 Mon Sep 17 00:00:00 2001 From: egregore Date: Mon, 2 Feb 2026 11:47:30 +0000 Subject: [PATCH] Initial commit: Egregore docs service Rust microservice using rouille for documentation management. Provides HTTP API for reading and updating STATUS.md, HISTORY.md, RUNBOOK.md. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 24 +++++ Cargo.toml | 10 ++ src/main.rs | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2936639 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Rust build artifacts +target/ +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.* + +# OS +.DS_Store +Thumbs.db + +# Secrets - BE PARANOID +*.pem +*.key +*.crt +credentials* +secrets* diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..206d193 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "docs" +version = "0.1.0" +edition = "2021" + +[dependencies] +rouille = "3.6" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..914f665 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,264 @@ +//! Egregore Docs Service +//! +//! A lightweight Rust microservice for managing documentation files. +//! Provides HTTP API for reading and updating STATUS.md, HISTORY.md, etc. + +use chrono::Utc; +use rouille::{router, Request, Response}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +const DOCS_DIR: &str = "/home/admin/docs"; +const BIND_ADDR: &str = "127.0.0.1:8083"; + +/// List of allowed document names (without .md extension) +const ALLOWED_DOCS: &[&str] = &["STATUS", "HISTORY", "RUNBOOK"]; + +#[derive(Serialize)] +struct DocInfo { + name: String, + path: String, + size: u64, + modified: String, +} + +#[derive(Serialize)] +struct DocContent { + name: String, + content: String, + modified: String, +} + +#[derive(Deserialize)] +struct UpdateRequest { + content: String, +} + +#[derive(Serialize)] +struct ApiResponse { + success: bool, + data: Option, + error: Option, +} + +#[derive(Serialize)] +struct HealthResponse { + status: String, + service: String, +} + +fn get_doc_path(name: &str) -> Option { + let upper_name = name.to_uppercase(); + if ALLOWED_DOCS.contains(&upper_name.as_str()) { + Some(PathBuf::from(DOCS_DIR).join(format!("{}.md", upper_name))) + } else { + None + } +} + +fn list_docs() -> Response { + let mut docs = Vec::new(); + + for name in ALLOWED_DOCS { + let path = PathBuf::from(DOCS_DIR).join(format!("{}.md", name)); + if let Ok(metadata) = fs::metadata(&path) { + let modified = metadata + .modified() + .map(|t| { + let datetime: chrono::DateTime = t.into(); + datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string() + }) + .unwrap_or_else(|_| "unknown".to_string()); + + docs.push(DocInfo { + name: name.to_string(), + path: path.to_string_lossy().to_string(), + size: metadata.len(), + modified, + }); + } + } + + Response::json(&ApiResponse { + success: true, + data: Some(docs), + error: None::, + }) +} + +fn get_doc(name: &str) -> Response { + let Some(path) = get_doc_path(name) else { + return Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Document '{}' not allowed", name)), + }) + .with_status_code(400); + }; + + match fs::read_to_string(&path) { + Ok(content) => { + let modified = fs::metadata(&path) + .and_then(|m| m.modified()) + .map(|t| { + let datetime: chrono::DateTime = t.into(); + datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string() + }) + .unwrap_or_else(|_| "unknown".to_string()); + + Response::json(&ApiResponse { + success: true, + data: Some(DocContent { + name: name.to_uppercase(), + content, + modified, + }), + error: None::, + }) + } + Err(e) => Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Failed to read document: {}", e)), + }) + .with_status_code(404), + } +} + +fn update_doc(name: &str, request: &Request) -> Response { + let Some(path) = get_doc_path(name) else { + return Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Document '{}' not allowed", name)), + }) + .with_status_code(400); + }; + + // Parse JSON body + let body: UpdateRequest = match rouille::input::json_input(request) { + Ok(b) => b, + Err(e) => { + return Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Invalid JSON: {}", e)), + }) + .with_status_code(400); + } + }; + + // Write to file + match fs::File::create(&path) { + Ok(mut file) => match file.write_all(body.content.as_bytes()) { + Ok(_) => { + let modified = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + Response::json(&ApiResponse { + success: true, + data: Some(DocContent { + name: name.to_uppercase(), + content: body.content, + modified, + }), + error: None::, + }) + } + Err(e) => Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Failed to write: {}", e)), + }) + .with_status_code(500), + }, + Err(e) => Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Failed to create file: {}", e)), + }) + .with_status_code(500), + } +} + +fn append_to_history(request: &Request) -> Response { + let path = PathBuf::from(DOCS_DIR).join("HISTORY.md"); + + #[derive(Deserialize)] + struct AppendRequest { + entry: String, + } + + let body: AppendRequest = match rouille::input::json_input(request) { + Ok(b) => b, + Err(e) => { + return Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Invalid JSON: {}", e)), + }) + .with_status_code(400); + } + }; + + // Read existing content + let mut content = fs::read_to_string(&path).unwrap_or_default(); + + // Find the marker line and insert before it + let marker = "*Log significant changes with date, actions, and outcomes*"; + if let Some(pos) = content.find(marker) { + let date = Utc::now().format("%Y-%m-%d").to_string(); + let new_entry = format!("\n## {} - {}\n\n---\n\n", date, body.entry); + content.insert_str(pos, &new_entry); + } else { + // Append at end if marker not found + content.push_str(&format!("\n## {}\n\n", body.entry)); + } + + match fs::write(&path, &content) { + Ok(_) => Response::json(&ApiResponse { + success: true, + data: Some("Entry added to HISTORY.md"), + error: None::, + }), + Err(e) => Response::json(&ApiResponse::<()> { + success: false, + data: None, + error: Some(format!("Failed to write: {}", e)), + }) + .with_status_code(500), + } +} + +fn health() -> Response { + Response::json(&HealthResponse { + status: "ok".to_string(), + service: "docs".to_string(), + }) +} + +fn handle_request(request: &Request) -> Response { + router!(request, + (GET) ["/health"] => { health() }, + (GET) ["/docs"] => { list_docs() }, + (GET) ["/docs/{name}", name: String] => { get_doc(&name) }, + (PUT) ["/docs/{name}", name: String] => { update_doc(&name, request) }, + (POST) ["/docs/history/append"] => { append_to_history(request) }, + _ => Response::empty_404() + ) +} + +fn main() { + println!("Egregore Docs Service starting on {}", BIND_ADDR); + println!("Serving documents from: {}", DOCS_DIR); + + rouille::start_server(BIND_ADDR, move |request| { + let response = handle_request(request); + + // Add CORS headers for local development + response + .with_additional_header("Access-Control-Allow-Origin", "*") + .with_additional_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS") + .with_additional_header("Access-Control-Allow-Headers", "Content-Type") + }); +}