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 }