#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs};
const NOTEBOOK: &str = r#"{
"cells": [
{
"cell_type": "code",
"execution_count": 9,
"id": "cafe0001",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": ["huge plotly blob\n"]
}
],
"source": ["print('hi')"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}
"#;
struct TempRepo(PathBuf);
impl TempRepo {
fn new(name: &str) -> Self {
let dir = env::temp_dir().join(format!("nbstrip-test-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
run(&dir, "git", &["init", "-q"]);
Self(dir)
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn run(dir: &Path, cmd: &str, args: &[&str]) -> String {
let out = Command::new(cmd)
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("running {cmd}: {e}"));
assert!(
out.status.success(),
"{cmd} {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn install_then_git_add_stages_stripped_notebooks() {
let repo = TempRepo::new("install");
let dir = &repo.0;
run(dir, env!("CARGO_BIN_EXE_nbstrip"), &["install"]);
let clean = run(dir, "git", &["config", "--get", "filter.nbstrip.clean"]);
assert!(clean.contains("nbstrip"), "clean filter: {clean}");
let smudge = run(dir, "git", &["config", "--get", "filter.nbstrip.smudge"]);
assert_eq!(smudge.trim(), "cat");
let required = run(dir, "git", &["config", "--get", "filter.nbstrip.required"]);
assert_eq!(required.trim(), "true");
let attrs = fs::read_to_string(dir.join(".git/info/attributes")).unwrap();
assert!(attrs.contains("*.ipynb filter=nbstrip"));
fs::write(dir.join("nb.ipynb"), NOTEBOOK).unwrap();
run(dir, "git", &["add", "nb.ipynb"]);
let staged = run(dir, "git", &["show", ":nb.ipynb"]);
assert!(staged.contains("\"outputs\": []"), "staged: {staged}");
assert!(staged.contains("\"execution_count\": null"));
assert!(!staged.contains("huge plotly blob"));
let worktree = fs::read_to_string(dir.join("nb.ipynb")).unwrap();
assert!(worktree.contains("huge plotly blob"));
}
#[test]
fn checkout_restores_notebooks_after_install() {
let repo = TempRepo::new("checkout");
let dir = &repo.0;
run(dir, env!("CARGO_BIN_EXE_nbstrip"), &["install"]);
fs::write(dir.join("nb.ipynb"), NOTEBOOK).unwrap();
run(dir, "git", &["add", "nb.ipynb"]);
run(
dir,
"git",
&[
"-c",
"user.email=nbstrip@test",
"-c",
"user.name=nbstrip",
"commit",
"-qm",
"add notebook",
],
);
fs::remove_file(dir.join("nb.ipynb")).unwrap();
run(dir, "git", &["checkout", "--", "nb.ipynb"]);
let restored = fs::read_to_string(dir.join("nb.ipynb")).unwrap();
assert!(restored.contains("\"outputs\": []"), "restored: {restored}");
}
#[test]
fn install_is_idempotent() {
let repo = TempRepo::new("idempotent");
let dir = &repo.0;
run(dir, env!("CARGO_BIN_EXE_nbstrip"), &["install"]);
run(dir, env!("CARGO_BIN_EXE_nbstrip"), &["install"]);
let attrs = fs::read_to_string(dir.join(".git/info/attributes")).unwrap();
let hits = attrs
.lines()
.filter(|l| l.trim() == "*.ipynb filter=nbstrip")
.count();
assert_eq!(hits, 1, "attribute duplicated:\n{attrs}");
}
#[test]
fn install_outside_a_repo_fails() {
let dir = env::temp_dir().join(format!("nbstrip-test-{}-norepo", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_nbstrip"))
.arg("install")
.current_dir(&dir)
.env("GIT_CEILING_DIRECTORIES", env::temp_dir())
.output()
.unwrap();
assert!(!out.status.success());
let _ = fs::remove_dir_all(&dir);
}
fn hg_available() -> bool {
Command::new("hg")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
fn hg_dir(name: &str) -> PathBuf {
let dir = env::temp_dir().join(format!("nbstrip-test-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
run(&dir, "hg", &["init"]);
dir
}
#[test]
fn install_then_hg_commit_stores_stripped_notebooks() {
if !hg_available() {
eprintln!("hg not installed; skipping");
return;
}
let dir = hg_dir("hg-install");
run(&dir, env!("CARGO_BIN_EXE_nbstrip"), &["install"]);
let hgrc = fs::read_to_string(dir.join(".hg/hgrc")).unwrap();
assert!(hgrc.contains("[encode]"), "hgrc: {hgrc}");
assert!(hgrc.contains("**.ipynb = pipe: "), "hgrc: {hgrc}");
assert!(hgrc.contains("[hooks]"), "hgrc: {hgrc}");
assert!(hgrc.contains("precommit.nbstrip = "), "hgrc: {hgrc}");
fs::write(dir.join("nb.ipynb"), NOTEBOOK).unwrap();
run(&dir, "hg", &["add", "nb.ipynb"]);
let out = Command::new("hg")
.args(["commit", "-m", "add notebook"])
.env("HGUSER", "nbstrip-test")
.current_dir(&dir)
.output()
.unwrap();
assert!(
out.status.success(),
"hg commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stored = run(&dir, "hg", &["cat", "nb.ipynb"]);
assert!(stored.contains("\"outputs\": []"), "stored: {stored}");
assert!(!stored.contains("huge plotly blob"));
let worktree = fs::read_to_string(dir.join("nb.ipynb")).unwrap();
assert!(worktree.contains("huge plotly blob"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn hg_install_is_idempotent_and_preserves_hgrc() {
if !hg_available() {
eprintln!("hg not installed; skipping");
return;
}
let dir = hg_dir("hg-idempotent");
fs::write(
dir.join(".hg/hgrc"),
"[ui]\nusername = keep me\n\n[hooks]\nprecommit.keepme = true\n\n[encode]\n**.gz = pipe: gunzip\n",
)
.unwrap();
run(&dir, env!("CARGO_BIN_EXE_nbstrip"), &["install"]);
run(&dir, env!("CARGO_BIN_EXE_nbstrip"), &["install"]);
let hgrc = fs::read_to_string(dir.join(".hg/hgrc")).unwrap();
let ours = hgrc.lines().filter(|l| l.starts_with("**.ipynb")).count();
assert_eq!(ours, 1, "filter line duplicated:\n{hgrc}");
let hooks = hgrc
.lines()
.filter(|l| l.starts_with("precommit.nbstrip"))
.count();
assert_eq!(hooks, 1, "hook line duplicated:\n{hgrc}");
assert!(hgrc.contains("username = keep me"), "hgrc: {hgrc}");
assert!(hgrc.contains("precommit.keepme = true"), "hgrc: {hgrc}");
assert!(hgrc.contains("**.gz = pipe: gunzip"), "hgrc: {hgrc}");
assert_eq!(hgrc.matches("[encode]").count(), 1, "hgrc: {hgrc}");
assert_eq!(hgrc.matches("[hooks]").count(), 1, "hgrc: {hgrc}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn hg_commit_aborts_when_filter_binary_is_missing() {
if !hg_available() {
eprintln!("hg not installed; skipping");
return;
}
let dir = hg_dir("hg-missing-binary");
let doomed = dir.join("nbstrip-doomed");
fs::copy(env!("CARGO_BIN_EXE_nbstrip"), &doomed).unwrap();
run(&dir, doomed.to_str().unwrap(), &["install"]);
fs::remove_file(&doomed).unwrap();
fs::write(dir.join("nb.ipynb"), NOTEBOOK).unwrap();
run(&dir, "hg", &["add", "nb.ipynb"]);
let out = Command::new("hg")
.args(["commit", "-m", "add notebook"])
.env("HGUSER", "nbstrip-test")
.current_dir(&dir)
.output()
.unwrap();
assert!(
!out.status.success(),
"commit should abort when the filter binary is gone: {}",
String::from_utf8_lossy(&out.stdout)
);
let log = run(&dir, "hg", &["log", "-T", "{node}"]);
assert_eq!(log.trim(), "", "a commit slipped through: {log}");
let worktree = fs::read_to_string(dir.join("nb.ipynb")).unwrap();
assert!(worktree.contains("huge plotly blob"));
let _ = fs::remove_dir_all(&dir);
}