use anyhow::{Context, Result, bail};
use std::fs;
use std::path::Path;
const TEMPLATE_LIB: &str = include_str!("../docs/fy-spec/lib.typ");
const STARTER_MAIN: &str = r#"#import "fy-spec/lib.typ": *
#show: project_book.with(
title: "{{NAME}} Specification",
subtitle: none,
version: "{{VERSION}}",
author: "{{AUTHOR}}",
date: datetime.today().display("[year]-[month]-[day]"),
lang: "en", // change to "zh" or "zh-CN" for Chinese documents
)
= Overview
This document was initialized by `cargo fy-docs init`. Add your module specifications in `docs/modules/` and `#include` them here.
// #include "modules/example.typ"
"#;
pub fn init(cwd: &Path) -> Result<()> {
let docs_dir = cwd.join("docs");
let entry = docs_dir.join("main.typ");
if entry.is_file() {
bail!(
"`{}` already exists — remove it first if you want to re-initialize",
crate::state::display_path(&entry)
);
}
let (name, version, author) = read_cargo_meta(cwd);
fs::create_dir_all(docs_dir.join("modules")).context("could not create docs/modules/")?;
fs::create_dir_all(docs_dir.join("fy-spec")).context("could not create docs/fy-spec/")?;
fs::write(docs_dir.join("fy-spec").join("lib.typ"), TEMPLATE_LIB)
.context("could not write docs/fy-spec/lib.typ")?;
let main_content = STARTER_MAIN
.replace("{{NAME}}", &name)
.replace("{{VERSION}}", &version)
.replace("{{AUTHOR}}", &author);
fs::write(&entry, main_content).context("could not write docs/main.typ")?;
ensure_gitignore(cwd, &["/docs/target/", "/docs/release/"]);
crate::state::log(&format!(
"[fy-docs] initialized {}",
crate::state::display_path(&docs_dir)
));
crate::state::log("[fy-docs] created docs/main.typ");
crate::state::log("[fy-docs] created docs/fy-spec/lib.typ");
crate::state::log("[fy-docs] created docs/modules/");
crate::state::log("[fy-docs] → run `cargo fy-docs` to preview");
Ok(())
}
fn read_cargo_meta(cwd: &Path) -> (String, String, String) {
let fallback_name = cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_owned());
let Ok(text) = fs::read_to_string(cwd.join("Cargo.toml")) else {
return (fallback_name, "0.1.0".to_owned(), "TODO".to_owned());
};
let Ok(manifest) = text.parse::<toml::Table>() else {
return (fallback_name, "0.1.0".to_owned(), "TODO".to_owned());
};
let package = manifest.get("package").and_then(toml::Value::as_table);
let name = package
.and_then(|p| p.get("name"))
.and_then(toml::Value::as_str)
.map(str::to_owned)
.unwrap_or(fallback_name);
let version = package
.and_then(|p| p.get("version"))
.and_then(toml::Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| "0.1.0".to_owned());
let author = package
.and_then(|p| p.get("authors"))
.and_then(toml::Value::as_array)
.and_then(|authors| authors.first())
.and_then(toml::Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| "TODO".to_owned());
(name, version, author)
}
fn ensure_gitignore(cwd: &Path, entries: &[&str]) {
let gitignore = cwd.join(".gitignore");
let mut content = fs::read_to_string(&gitignore).unwrap_or_default();
let mut changed = false;
for &entry in entries {
if !content.lines().any(|line| line.trim() == entry) {
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(entry);
content.push('\n');
changed = true;
}
}
if changed {
if let Err(err) = fs::write(&gitignore, content) {
crate::state::log(&format!(
"[fy-docs] could not update {}: {err}",
gitignore.display()
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaffolds_docs_directory() {
let temp = std::env::temp_dir().join(format!("fy-docs-init-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
std::fs::write(
temp.join("Cargo.toml"),
"[package]\nname = \"test-project\"\nversion = \"1.0.0\"\nauthors = [\"Tester <t@example.com>\"]\n",
)
.unwrap();
init(&temp).unwrap();
assert!(temp.join("docs/main.typ").is_file());
assert!(temp.join("docs/fy-spec/lib.typ").is_file());
assert!(temp.join("docs/modules").is_dir());
let main = std::fs::read_to_string(temp.join("docs/main.typ")).unwrap();
assert!(main.contains("test-project"));
assert!(main.contains("1.0.0"));
assert!(main.contains("Tester"));
assert!(!main.contains("fengyangsi"));
std::fs::remove_dir_all(temp).unwrap();
}
#[test]
fn refuses_to_overwrite_existing_main() {
let temp = std::env::temp_dir().join(format!("fy-docs-init-dup-{}", std::process::id()));
std::fs::create_dir_all(temp.join("docs")).unwrap();
std::fs::write(temp.join("docs/main.typ"), "existing").unwrap();
let result = init(&temp);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
std::fs::remove_dir_all(temp).unwrap();
}
#[test]
fn falls_back_without_cargo_toml() {
let temp =
std::env::temp_dir().join(format!("fy-docs-init-no-cargo-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
init(&temp).unwrap();
let main = std::fs::read_to_string(temp.join("docs/main.typ")).unwrap();
assert!(main.contains("0.1.0"));
assert!(main.contains(r#"author: "TODO""#));
std::fs::remove_dir_all(temp).unwrap();
}
}