1 //! f0rge #68: small robustness/consistency batch.2 //! - `hu13 build .` names the repo after its directory, not "repo"3 //! - gitlinks (mode 160000) get no raw copy / file page4 //! - `inbox list` escapes a hostile id5 //! - `.forge` symlinks are skipped like wiki/artifact symlinks6 //! - wiki root pages do not repeat their filename after the title7 8 use std::path::{Path, PathBuf};9 use std::process::Command;10 11 fn fresh(prefix: &str) -> PathBuf {12 let dir = std::env::temp_dir().join(format!(13 "hu13-polish-{prefix}-{}-{}",14 std::process::id(),15 std::time::SystemTime::now()16 .duration_since(std::time::UNIX_EPOCH)17 .map(|d| d.as_nanos())18 .unwrap_or(0)19 ));20 std::fs::create_dir_all(&dir).unwrap();21 dir22 }23 24 fn git(repo: &Path, args: &[&str]) -> String {25 let out = Command::new("git")26 .arg("-C")27 .arg(repo)28 .args(args)29 .output()30 .expect("spawn git");31 assert!(32 out.status.success(),33 "git {args:?}: {}",34 String::from_utf8_lossy(&out.stderr)35 );36 String::from_utf8_lossy(&out.stdout).trim().to_string()37 }38 39 fn init(repo: &Path) {40 git(repo, &["init", "-b", "main"]);41 git(repo, &["config", "user.name", "hu13-test"]);42 git(repo, &["config", "user.email", "[email protected]"]);43 git(repo, &["config", "commit.gpgsign", "false"]);44 }45 46 fn build(repo: &Path, out: &Path, cwd: Option<&Path>) -> std::process::Output {47 let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));48 cmd.arg("build").arg(repo).arg("-o").arg(out);49 if let Some(dir) = cwd {50 cmd.current_dir(dir);51 }52 cmd.output().expect("spawn hu13 build")53 }54 55 #[test]56 fn dot_build_uses_directory_name() {57 let parent = fresh("dot");58 let repo = parent.join("my-project");59 std::fs::create_dir_all(&repo).unwrap();60 init(&repo);61 std::fs::write(repo.join("README.md"), "# dot\n").unwrap();62 git(&repo, &["add", "-A"]);63 git(&repo, &["commit", "-m", "one"]);64 let out = fresh("out");65 let res = build(Path::new("."), &out, Some(&repo));66 assert!(67 res.status.success(),68 "{}",69 String::from_utf8_lossy(&res.stderr)70 );71 let index = std::fs::read_to_string(out.join("index.html")).unwrap();72 assert!(73 index.contains("<h1>my-project</h1>"),74 "repo named after the directory: {}",75 &index[..600.min(index.len())]76 );77 assert!(!index.contains("<h1>repo</h1>"), "not the literal 'repo'");78 }79 80 #[test]81 fn gitlink_gets_no_raw_or_page() {82 let repo = fresh("gitlink");83 init(&repo);84 std::fs::write(repo.join("README.md"), "# gitlink\n").unwrap();85 git(&repo, &["add", "-A"]);86 git(&repo, &["commit", "-m", "one"]);87 let head = git(&repo, &["rev-parse", "HEAD"]);88 git(89 &repo,90 &[91 "update-index",92 "--add",93 "--cacheinfo",94 &format!("160000,{head},sub"),95 ],96 );97 // Entry name for --cacheinfo is the last argument.98 git(&repo, &["commit", "-m", "add gitlink"]);99 let out = fresh("out");100 let res = build(&repo, &out, None);101 assert!(102 res.status.success(),103 "{}",104 String::from_utf8_lossy(&res.stderr)105 );106 assert!(!out.join("raw/sub").exists(), "no raw copy for a gitlink");107 assert!(108 !out.join("files/sub.html").exists(),109 "no file page for a gitlink"110 );111 let index = std::fs::read_to_string(out.join("index.html")).unwrap();112 assert!(index.contains("@ submodule"), "gitlink marked in the table");113 }114 115 #[test]116 fn inbox_list_escapes_hostile_id() {117 let inbox = fresh("inbox");118 // A filename can contain ESC; list must never print it raw.119 let name = "bad\u{1b}id.json";120 std::fs::write(121 inbox.join(name),122 r#"{"type":"issue","title":"t","received_at":"2026-09-10T00:00:00Z"}"#,123 )124 .unwrap();125 let out = Command::new(env!("CARGO_BIN_EXE_hu13"))126 .arg("inbox")127 .arg("list")128 .arg("--inbox-dir")129 .arg(&inbox)130 .output()131 .unwrap();132 assert!(out.status.success());133 let stdout = String::from_utf8_lossy(&out.stdout);134 assert!(stdout.contains("\\x1b"), "escaped id: {stdout}");135 assert!(!stdout.contains('\u{1b}'), "no raw ESC: {stdout}");136 }137 138 #[test]139 fn forge_symlink_is_skipped() {140 let repo = fresh("symlink");141 init(&repo);142 std::fs::write(repo.join("README.md"), "# symlink\n").unwrap();143 let dir = repo.join(".forge/issues/aaaaaaaa-1111-4111-8111-111111111111");144 std::fs::create_dir_all(&dir).unwrap();145 let outside = fresh("outside").join("issue.md");146 std::fs::write(147 &outside,148 "---\nid: aaaaaaaa-1111-4111-8111-111111111111\ntitle: linked\nstate: open\ncreated: 2026-09-10T00:00:00Z\n---\nbody\n",149 )150 .unwrap();151 std::os::unix::fs::symlink(&outside, dir.join("issue.md")).unwrap();152 git(&repo, &["add", "-A"]);153 git(&repo, &["commit", "-m", "one"]);154 let out = fresh("out");155 let res = build(&repo, &out, None);156 assert!(157 res.status.success(),158 "{}",159 String::from_utf8_lossy(&res.stderr)160 );161 let err = String::from_utf8_lossy(&res.stderr);162 assert!(err.contains("symlink"), "warned about the symlink: {err}");163 assert!(164 !out.join("issues/aaaaaaaa-1111-4111-8111-111111111111.html")165 .exists(),166 "no page for a symlinked issue"167 );168 }169 170 #[test]171 fn wiki_root_page_does_not_repeat_its_filename() {172 let repo = fresh("wiki");173 init(&repo);174 std::fs::write(repo.join("README.md"), "# wiki\n").unwrap();175 std::fs::create_dir_all(repo.join(".forge/wiki/nested")).unwrap();176 std::fs::write(repo.join(".forge/wiki/README.md"), "# hu13 README\n").unwrap();177 std::fs::write(repo.join(".forge/wiki/nested/guide.md"), "# Guide\n").unwrap();178 git(&repo, &["add", "-A"]);179 git(&repo, &["commit", "-m", "one"]);180 let out = fresh("out");181 let res = build(&repo, &out, None);182 assert!(res.status.success());183 let index = std::fs::read_to_string(out.join("wiki/index.html")).unwrap();184 assert!(185 !index.contains("hu13 README</a> README"),186 "root page path hint dropped: {index}"187 );188 assert!(189 index.contains("Guide</a> <span class=\"muted\">nested/guide.md</span>"),190 "nested pages keep the path hint"191 );192 }