use std::fs;
use std::io::{self, Write};
use std::path::Path;
use anyhow::{Result, bail};
use crossterm::style::Stylize;
use crate::graph::model::{Graph, Node};
use crate::parser::{config, graph};
use crate::scanner::tree;
use crate::tangle;
pub fn run() -> Result<()> {
let root = std::env::current_dir()?;
run_in(&root, None)
}
pub fn run_in(root: &Path, accept_hooks: Option<bool>) -> Result<()> {
let tangle_dir = tangle::tangle_dir(root);
if tangle_dir.join("graph.tngl").exists() {
bail!("tngl is already initialised (tangle/graph.tngl exists). Run `tngl update` instead.");
}
fs::create_dir_all(&tangle_dir)?;
println!(
" {} {}",
"Scanning".cyan().bold(),
"repository tree...".dark_grey()
);
let paths = tree::scan(root)?;
let file_count = paths.iter().filter(|p| !p.ends_with('/')).count();
let dir_count = paths.iter().filter(|p| p.ends_with('/')).count();
println!(
" {} {} files and {} folders",
"Found".green().bold(),
file_count.to_string().green(),
dir_count.to_string().green()
);
let mut g = Graph::new();
for path in &paths {
g.add_node(Node::new(path.as_str()));
}
let graph_contents = graph::serialize(&graph::from_graph(&g));
fs::write(tangle_dir.join("graph.tngl"), &graph_contents)?;
println!(" {} tangle/graph.tngl", "Created".green().bold());
fs::write(tangle_dir.join("config.tngl"), config::DEFAULT_CONTENTS)?;
println!(" {} tangle/config.tngl", "Created".green().bold());
let tnglignore_path = root.join(".tnglignore");
if !tnglignore_path.exists() {
fs::write(&tnglignore_path, TNGLIGNORE_DEFAULTS)?;
println!(" {} .tnglignore", "Created".green().bold());
}
let wrapper_path = root.join("tngl");
if !wrapper_path.exists() {
fs::write(&wrapper_path, WRAPPER_SCRIPT)?;
set_executable(&wrapper_path)?;
println!(" {} tngl (wrapper script)", "Created".green().bold());
}
let install = match accept_hooks {
Some(v) => v,
None => prompt_yes_no(" Install git hooks? [y/n] ")?,
};
if install {
install_git_hooks(root)?;
}
Ok(())
}
fn install_git_hooks(root: &Path) -> Result<()> {
let hooks_dir = root.join(".git/hooks");
if !hooks_dir.exists() {
bail!(".git/hooks/ not found — is this a git repository?");
}
for hook in &["post-merge", "post-checkout"] {
let path = hooks_dir.join(hook);
fs::write(&path, GIT_HOOK_CONTENT)?;
set_executable(&path)?;
}
println!(
" {} git hooks (post-merge, post-checkout)",
"Installed".green().bold()
);
Ok(())
}
fn prompt_yes_no(prompt: &str) -> Result<bool> {
print!("{}", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(matches!(
input.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
const TNGLIGNORE_DEFAULTS: &str = "\
# .tnglignore
# Additional scan ignores for tngl update/open (gitignore syntax).
# Add project-specific patterns below, one per line.
";
const WRAPPER_SCRIPT: &str = "\
#!/bin/sh
# tngl wrapper — https://github.com/FrederikLizakJohansen/tngl
SCRIPT_DIR=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\"
SELF=\"$SCRIPT_DIR/tngl\"
TNGL_BIN=\"$(command -v tngl 2>/dev/null || true)\"
if [ -n \"$TNGL_BIN\" ] && [ \"$TNGL_BIN\" != \"$SELF\" ] && [ \"$TNGL_BIN\" != \"$0\" ]; then
exec \"$TNGL_BIN\" \"$@\"
elif [ -f \"$SCRIPT_DIR/Cargo.toml\" ] && command -v cargo >/dev/null 2>&1 && grep -q 'name = \"tngl\"' \"$SCRIPT_DIR/Cargo.toml\"; then
exec cargo run --manifest-path \"$SCRIPT_DIR/Cargo.toml\" --quiet -- \"$@\"
else
echo \"tngl is not installed.\"
echo \"Install it with: cargo install tngl\"
echo \"Or visit: https://github.com/FrederikLizakJohansen/tngl\"
exit 1
fi
";
const GIT_HOOK_CONTENT: &str = "#!/bin/sh\ntngl update --silent\n";
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup(files: &[&str]) -> TempDir {
let dir = TempDir::new().unwrap();
for f in files {
let p = dir.path().join(f);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, "").unwrap();
}
dir
}
#[test]
fn creates_tangle_directory() {
let dir = setup(&["src/main.rs"]);
run_in(dir.path(), Some(false)).unwrap();
assert!(dir.path().join("tangle").is_dir());
}
#[test]
fn creates_graph_tngl() {
let dir = setup(&["src/main.rs", "README.md"]);
run_in(dir.path(), Some(false)).unwrap();
let content = fs::read_to_string(dir.path().join("tangle/graph.tngl")).unwrap();
assert!(content.contains("src/main.rs"));
assert!(content.contains("README.md"));
}
#[test]
fn graph_tngl_all_nodes_are_orphans() {
let dir = setup(&["a.rs", "b.rs"]);
run_in(dir.path(), Some(false)).unwrap();
let content = fs::read_to_string(dir.path().join("tangle/graph.tngl")).unwrap();
assert!(!content.contains("->"));
assert!(!content.contains("--"));
}
#[test]
fn creates_config_tngl() {
let dir = setup(&[]);
run_in(dir.path(), Some(false)).unwrap();
let content = fs::read_to_string(dir.path().join("tangle/config.tngl")).unwrap();
assert!(content.contains("on_delete: prompt"));
}
#[test]
fn creates_tnglignore() {
let dir = setup(&[]);
run_in(dir.path(), Some(false)).unwrap();
assert!(dir.path().join(".tnglignore").exists());
let content = fs::read_to_string(dir.path().join(".tnglignore")).unwrap();
assert!(content.contains("# .tnglignore"));
assert!(content.contains("Add project-specific patterns"));
}
#[test]
fn does_not_overwrite_existing_tnglignore() {
let dir = setup(&[]);
fs::write(dir.path().join(".tnglignore"), "my_custom_rule/\n").unwrap();
run_in(dir.path(), Some(false)).unwrap();
let content = fs::read_to_string(dir.path().join(".tnglignore")).unwrap();
assert!(content.contains("my_custom_rule/"));
assert!(!content.contains("Add project-specific patterns"));
}
#[test]
fn creates_wrapper_script() {
let dir = setup(&[]);
run_in(dir.path(), Some(false)).unwrap();
assert!(dir.path().join("tngl").exists());
let content = fs::read_to_string(dir.path().join("tngl")).unwrap();
assert!(content.contains("#!/bin/sh"));
assert!(content.contains("SCRIPT_DIR="));
assert!(content.contains("SELF=\"$SCRIPT_DIR/tngl\""));
assert!(content.contains("TNGL_BIN=\"$(command -v tngl 2>/dev/null || true)\""));
assert!(content.contains("[ \"$TNGL_BIN\" != \"$SELF\" ]"));
assert!(
content.contains("cargo run --manifest-path \"$SCRIPT_DIR/Cargo.toml\" --quiet --")
);
assert!(content.contains("cargo install tngl"));
}
#[test]
fn does_not_overwrite_existing_wrapper() {
let dir = setup(&[]);
fs::write(dir.path().join("tngl"), "custom content").unwrap();
run_in(dir.path(), Some(false)).unwrap();
let content = fs::read_to_string(dir.path().join("tngl")).unwrap();
assert_eq!(content, "custom content");
}
#[test]
fn error_if_already_initialised() {
let dir = setup(&[]);
run_in(dir.path(), Some(false)).unwrap();
assert!(run_in(dir.path(), Some(false)).is_err());
}
#[test]
fn installs_git_hooks_when_accepted() {
let dir = setup(&[]);
fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
run_in(dir.path(), Some(true)).unwrap();
assert!(dir.path().join(".git/hooks/post-merge").exists());
assert!(dir.path().join(".git/hooks/post-checkout").exists());
}
#[test]
fn skips_git_hooks_when_declined() {
let dir = setup(&[]);
run_in(dir.path(), Some(false)).unwrap();
assert!(!dir.path().join(".git/hooks/post-merge").exists());
}
#[test]
fn tangle_dir_excluded_from_graph() {
let dir = setup(&["src/main.rs"]);
fs::create_dir_all(dir.path().join("tangle")).unwrap();
fs::write(dir.path().join("tangle/old.tngl"), "").unwrap();
run_in(dir.path(), Some(false)).unwrap();
let content = fs::read_to_string(dir.path().join("tangle/graph.tngl")).unwrap();
assert!(!content.contains("old.tngl"));
assert!(!content.contains("graph.tngl"));
}
}