src/main.rs 15.5 KiB 516 lines Raw
1 mod agents;2 mod api;3 mod build;4 mod commits;5 mod feed;6 mod forge;7 mod gitx;8 mod highlight;9 mod inbox;10 mod layout;11 mod logo;12 mod paths;13 mod releases;14 mod serve;15 mod wiki;16 17 const VERSION: &str = env!("CARGO_PKG_VERSION");18 19 fn print_top_help() {20     print!(21         "\22 hu13 {VERSION} — tiny static-site generator + text-only inbox for one repo23 24 USAGE:25     hu13 <SUBCOMMAND> [OPTIONS]26     hu13 --version27     hu13 --help28 29 SUBCOMMANDS:30     build     Build a static site from a repo checkout31     serve     Serve a built site plus the two inbox POSTs32     inbox     Inspect the local inbox (list|show)33     version   Print version34 35 Run `hu13 <subcommand> --help` for subcommand help.36 "37     );38 }39 40 fn print_build_help() {41     print!(42         "\43 hu13-build — build a static site from a repo checkout44 45 USAGE:46     hu13 build <REPO> -o <OUT> [OPTIONS]47 48 ARGS:49     <REPO>    Path to the repo checkout to render50 51 OPTIONS:52     -o <OUT>               Output directory for the static site53         --branch <NAME>    Branch to pin (else HU13_BRANCH, else try main/master)54         --clone-url <URL>  Clone URL to advertise (else git origin remote, else none)55         --public-origin <URL> Public base URL of this server (else HU13_PUBLIC_ORIGIN, else unset;56                            when set, clone block advertises `<origin>/repo.git`)57         --repo-name <S>    Repo display name (else HU13_REPO_NAME, else repo dir name;58                            --title is a deprecated alias; empty falls back, never blank)59         --description <S>  Site description (else HU13_DESCRIPTION, else hidden;60                            empty hides the header line, no empty <p>)61         --max-log <N>      Max commits in log [default: 100]62     -h, --help             Print this help63 "64     );65 }66 67 fn print_serve_help() {68     print!(69         "\70 hu13-serve — serve a built site plus the two inbox POSTs71 72 USAGE:73     hu13 serve [OPTIONS]74 75 OPTIONS:76         --www <DIR>          Built site directory to serve77         --inbox-dir <DIR>    Inbox directory for POST storage (must sit OUTSIDE --www)78         --listen <ADDR>      Listen address [default: 0.0.0.0:39313]79         --max-title <N>      Max title chars [default: 300]80         --max-body <N>       Max body/prompt chars [default: 20000]81         --max-bytes <N>      Max whole POST bytes [default: 32768]82         --rate-per-hour <N>  Per-IP POSTs per hour [default: 2]83         --quota <N>          Global quota in files [default: 500]84     -h, --help               Print this help85 86 ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN, HU13_MAX_TITLE, HU13_MAX_BODY,87      HU13_MAX_BYTES, HU13_RATE_PER_HOUR, HU13_QUOTA (flags win).88      No config file, ever.89 POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, ack id, no echo).90 "91     );92 }93 94 fn print_inbox_help() {95     print!(96         "\97 hu13-inbox — inspect the local inbox (local only, never over HTTP)98 99 USAGE:100     hu13 inbox list [--inbox-dir <DIR>]101     hu13 inbox show <UUID> [--inbox-dir <DIR>]102 103 OPTIONS:104         --inbox-dir <DIR>  Inbox directory to read (else HU13_INBOX_DIR)105     -h, --help             Print this help106 107 SAFETY: output is escaped plain text. Never paste inbox text into a shell. See gl0w.md.108 "109     );110 }111 112 fn wants_help(arg: &str) -> bool {113     arg == "--help" || arg == "-h" || arg == "help"114 }115 116 fn run_build(args: &[String]) -> i32 {117     if args.iter().any(|a| wants_help(a)) {118         print_build_help();119         return 0;120     }121     let mut repo: Option<String> = None;122     let mut out: Option<String> = None;123     let mut branch: Option<String> = None;124     let mut clone_url: Option<String> = None;125     let mut public_origin: Option<String> = None;126     let mut repo_name: Option<String> = None;127     let mut description: Option<String> = None;128     let mut max_log: usize = 100;129     let mut i = 0;130     while i < args.len() {131         match args[i].as_str() {132             "-o" | "--output" => {133                 i += 1;134                 if i >= args.len() {135                     eprintln!("hu13 build: -o needs a value");136                     return 2;137                 }138                 out = Some(args[i].clone());139             }140             "--branch" => {141                 i += 1;142                 if i >= args.len() {143                     eprintln!("hu13 build: --branch needs a value");144                     return 2;145                 }146                 branch = Some(args[i].clone());147             }148             "--clone-url" => {149                 i += 1;150                 if i >= args.len() {151                     eprintln!("hu13 build: --clone-url needs a value");152                     return 2;153                 }154                 clone_url = Some(args[i].clone());155             }156             "--public-origin" => {157                 i += 1;158                 if i >= args.len() {159                     eprintln!("hu13 build: --public-origin needs a value");160                     return 2;161                 }162                 public_origin = Some(args[i].clone());163             }164             "--title" | "--repo-name" => {165                 i += 1;166                 if i >= args.len() {167                     eprintln!("hu13 build: --repo-name needs a value");168                     return 2;169                 }170                 repo_name = Some(args[i].clone());171             }172             "--description" => {173                 i += 1;174                 if i >= args.len() {175                     eprintln!("hu13 build: --description needs a value");176                     return 2;177                 }178                 description = Some(args[i].clone());179             }180             "--max-log" => {181                 i += 1;182                 if i >= args.len() {183                     eprintln!("hu13 build: --max-log needs a value");184                     return 2;185                 }186                 match args[i].parse::<usize>() {187                     Ok(n) => max_log = n,188                     Err(_) => {189                         eprintln!("hu13 build: --max-log must be a number");190                         return 2;191                     }192                 }193             }194             s if s.starts_with('-') => {195                 eprintln!("hu13 build: unknown option '{s}'");196                 return 2;197             }198             s => {199                 if repo.is_some() {200                     eprintln!("hu13 build: unexpected argument '{s}'");201                     return 2;202                 }203                 repo = Some(s.to_string());204             }205         }206         i += 1;207     }208     let (Some(repo), Some(out)) = (repo, out) else {209         eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--public-origin URL] [--repo-name S] [--description S] [--max-log N]");210         return 2;211     };212     let opts = build::BuildOptions {213         branch_flag: branch,214         clone_url_flag: clone_url,215         public_origin_flag: public_origin,216         repo_name_flag: repo_name,217         description_flag: description,218         max_log,219     };220     match build::run_build(221         std::path::Path::new(&repo),222         std::path::Path::new(&out),223         &opts,224     ) {225         Ok(summary) => {226             println!(227                 "hu13: built branch {} @ {} ({} files) -> {}",228                 summary.branch, summary.short_sha, summary.file_count, out229             );230             0231         }232         Err(e) => {233             eprintln!("{e}");234             1235         }236     }237 }238 239 fn env_or(opt: Option<String>, name: &str) -> Option<String> {240     opt.or_else(|| std::env::var(name).ok().filter(|s| !s.trim().is_empty()))241 }242 243 fn run_serve(args: &[String]) -> i32 {244     if args.iter().any(|a| wants_help(a)) {245         print_serve_help();246         return 0;247     }248     let mut www: Option<String> = None;249     let mut inbox_dir: Option<String> = None;250     let mut listen: Option<String> = None;251     let mut max_title: Option<String> = None;252     let mut max_body: Option<String> = None;253     let mut max_bytes: Option<String> = None;254     let mut rate_per_hour: Option<String> = None;255     let mut quota: Option<String> = None;256     let need = |args: &[String], i: &mut usize, flag: &str| -> Option<String> {257         *i += 1;258         if *i >= args.len() {259             eprintln!("hu13 serve: {flag} needs a value");260             return None;261         }262         Some(args[*i].clone())263     };264     let mut i = 0;265     while i < args.len() {266         match args[i].as_str() {267             "--www" => {268                 let Some(v) = need(args, &mut i, "--www") else {269                     return 2;270                 };271                 www = Some(v);272             }273             "--inbox-dir" => {274                 let Some(v) = need(args, &mut i, "--inbox-dir") else {275                     return 2;276                 };277                 inbox_dir = Some(v);278             }279             "--listen" => {280                 let Some(v) = need(args, &mut i, "--listen") else {281                     return 2;282                 };283                 listen = Some(v);284             }285             "--max-title" => {286                 let Some(v) = need(args, &mut i, "--max-title") else {287                     return 2;288                 };289                 max_title = Some(v);290             }291             "--max-body" => {292                 let Some(v) = need(args, &mut i, "--max-body") else {293                     return 2;294                 };295                 max_body = Some(v);296             }297             "--max-bytes" => {298                 let Some(v) = need(args, &mut i, "--max-bytes") else {299                     return 2;300                 };301                 max_bytes = Some(v);302             }303             "--rate-per-hour" => {304                 let Some(v) = need(args, &mut i, "--rate-per-hour") else {305                     return 2;306                 };307                 rate_per_hour = Some(v);308             }309             "--quota" => {310                 let Some(v) = need(args, &mut i, "--quota") else {311                     return 2;312                 };313                 quota = Some(v);314             }315             s => {316                 eprintln!("hu13 serve: unknown option '{s}'");317                 return 2;318             }319         }320         i += 1;321     }322     fn parse_num(raw: Option<String>, env: &str, what: &str, dflt: usize) -> Option<usize> {323         let s = raw.or_else(|| std::env::var(env).ok().filter(|v| !v.trim().is_empty()));324         let Some(s) = s else { return Some(dflt) };325         match s.trim().parse::<usize>() {326             Ok(n) if n > 0 => Some(n),327             _ => {328                 eprintln!("hu13 serve: {what} (else {env}) must be a positive number, got '{s}'");329                 None330             }331         }332     }333     let www = env_or(www, "HU13_WWW");334     let inbox_dir = env_or(inbox_dir, "HU13_INBOX_DIR");335     let listen = env_or(listen, "HU13_LISTEN").unwrap_or_else(|| "0.0.0.0:39313".to_string());336     let (Some(www), Some(inbox_dir)) = (www, inbox_dir) else {337         eprintln!("hu13 serve: usage: hu13 serve --www <DIR> --inbox-dir <DIR> [--listen <ADDR>]");338         return 2;339     };340     let Some(max_title) = parse_num(341         max_title,342         "HU13_MAX_TITLE",343         "--max-title",344         crate::inbox::MAX_TITLE_CHARS,345     ) else {346         return 2;347     };348     let Some(max_body) = parse_num(349         max_body,350         "HU13_MAX_BODY",351         "--max-body",352         crate::inbox::MAX_BODY_CHARS,353     ) else {354         return 2;355     };356     let Some(max_bytes) = parse_num(357         max_bytes,358         "HU13_MAX_BYTES",359         "--max-bytes",360         crate::inbox::MAX_REQUEST_BYTES,361     ) else {362         return 2;363     };364     let Some(rate_per_hour) = parse_num(365         rate_per_hour,366         "HU13_RATE_PER_HOUR",367         "--rate-per-hour",368         crate::inbox::RATE_PER_HOUR as usize,369     ) else {370         return 2;371     };372     let Some(quota) = parse_num(quota, "HU13_QUOTA", "--quota", crate::inbox::QUOTA_FILES) else {373         return 2;374     };375     let mut opts = serve::ServeOptions::with_defaults(376         std::path::PathBuf::from(www),377         std::path::PathBuf::from(inbox_dir),378         listen,379     );380     opts.max_title = max_title;381     opts.max_body = max_body;382     opts.max_bytes = max_bytes;383     opts.rate_per_hour = rate_per_hour as u32;384     opts.quota = quota;385     serve::run(&opts)386 }387 388 fn run_inbox(args: &[String]) -> i32 {389     if args.iter().any(|a| wants_help(a)) {390         print_inbox_help();391         return 0;392     }393     if args.is_empty() {394         print_inbox_help();395         return 0;396     }397     let sub = args[0].as_str();398     let mut inbox_dir: Option<String> = None;399     let mut id: Option<String> = None;400     let mut i = 1;401     while i < args.len() {402         match args[i].as_str() {403             "--inbox-dir" => {404                 i += 1;405                 if i >= args.len() {406                     eprintln!("hu13 inbox: --inbox-dir needs a value");407                     return 2;408                 }409                 inbox_dir = Some(args[i].clone());410             }411             s if s.starts_with('-') => {412                 eprintln!("hu13 inbox: unknown option '{s}'");413                 return 2;414             }415             s => {416                 if id.is_some() {417                     eprintln!("hu13 inbox: unexpected argument '{s}'");418                     return 2;419                 }420                 id = Some(s.to_string());421             }422         }423         i += 1;424     }425     let inbox_dir = env_or(inbox_dir, "HU13_INBOX_DIR");426     let Some(inbox_dir) = inbox_dir else {427         eprintln!("hu13 inbox: usage: hu13 inbox <list|show> --inbox-dir <DIR>");428         return 2;429     };430     let dir = std::path::Path::new(&inbox_dir);431     match sub {432         "list" => {433             if id.is_some() {434                 eprintln!("hu13 inbox: list takes no id");435                 return 2;436             }437             inbox::cmd_list(dir)438         }439         "show" => {440             let Some(id) = id else {441                 eprintln!("hu13 inbox: usage: hu13 inbox show <UUID> --inbox-dir <DIR>");442                 return 2;443             };444             inbox::cmd_show(dir, &id)445         }446         _ => {447             eprintln!("hu13: unknown inbox subcommand '{sub}'");448             print_inbox_help();449             2450         }451     }452 }453 454 fn main() {455     let code = real_main();456     std::process::exit(code);457 }458 459 fn real_main() -> i32 {460     let args: Vec<String> = std::env::args().collect();461     let rest = &args[1..];462     if rest.is_empty() {463         print_top_help();464         return 0;465     }466     match rest[0].as_str() {467         "--version" | "-V" | "version" => {468             println!("hu13 {VERSION}");469             // Owner logo (#26): on a tty also show the ANSI block-art470             // interpreted (ESC-prefixed so colors render); pipes stay a471             // single plain version line. Zero-dep via IsTerminal.472             if std::io::IsTerminal::is_terminal(&std::io::stdout()) {473                 print!("{}", crate::logo::logo_ansi_for_tty());474             }475             0476         }477         "--help" | "-h" | "help" => {478             if rest.len() > 1 {479                 match rest[1].as_str() {480                     "build" => print_build_help(),481                     "serve" => print_serve_help(),482                     "inbox" => print_inbox_help(),483                     _ => print_top_help(),484                 }485             } else {486                 print_top_help();487             }488             0489         }490         "build" => run_build(&rest[1..]),491         "serve" => run_serve(&rest[1..]),492         "inbox" => run_inbox(&rest[1..]),493         other => {494             eprintln!("hu13: unknown subcommand '{other}'");495             print_top_help();496             2497         }498     }499 }500 501 #[cfg(test)]502 mod tests {503     use super::layout;504 505     #[test]506     fn version_is_nonempty() {507         assert!(!super::VERSION.is_empty());508     }509 510     #[test]511     fn top_help_mentions_subcommands() {512         // Smoke: layout healthcheck renders a page stub.513         let html = layout::healthcheck_page();514         assert!(html.contains("ok"));515     }516 }