use std::fs;
use super::common::{hyalo, hyalo_no_hints, write_md};
use tempfile::TempDir;
fn setup_empty() -> TempDir {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: Test\n---\nBody.");
fs::write(tmp.path().join(".hyalo.toml"), "dir = \".\"\n").unwrap();
tmp
}
fn setup_with_type() -> TempDir {
let tmp = setup_empty();
fs::write(
tmp.path().join(".hyalo.toml"),
r#"dir = "."
[schema.default]
required = ["title"]
[schema.types.note]
required = ["title", "date"]
"#,
)
.unwrap();
tmp
}
#[test]
fn types_list_empty() {
let tmp = setup_empty();
let output = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "list"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["total"], 0);
assert!(json["results"].as_array().unwrap().is_empty());
}
#[test]
fn types_list_with_type() {
let tmp = setup_with_type();
let output = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "list"])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["total"], 1);
assert_eq!(json["results"][0]["type"], "note");
}
#[test]
fn types_bare_is_alias_for_list() {
let tmp = setup_with_type();
let out_bare = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types"])
.output()
.unwrap();
let out_list = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "list"])
.output()
.unwrap();
assert!(out_bare.status.success());
assert_eq!(out_bare.stdout, out_list.stdout);
}
#[test]
fn types_show_unknown_type_exits_nonzero() {
let tmp = setup_empty();
let output = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "show", "ghost"])
.output()
.unwrap();
assert!(!output.status.success());
}
#[test]
fn types_show_existing_type() {
let tmp = setup_with_type();
let output = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "show", "note"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(json["results"]["type"], "note");
let required = json["results"]["required"].as_array().unwrap();
assert!(required.contains(&serde_json::json!("title")));
assert!(required.contains(&serde_json::json!("date")));
}
#[test]
fn types_remove_existing_type() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "remove", "note"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let list_out = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "list"])
.output()
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&list_out.stdout).unwrap();
assert_eq!(json["total"], 0);
}
#[test]
fn types_remove_nonexistent_exits_nonzero() {
let tmp = setup_empty();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "remove", "ghost"])
.output()
.unwrap();
assert!(!output.status.success());
}
#[test]
fn types_set_required_adds_field() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "status"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let show = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "show", "note"])
.output()
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&show.stdout).unwrap();
let required = json["results"]["required"].as_array().unwrap();
assert!(required.contains(&serde_json::json!("status")));
}
#[test]
fn types_set_required_no_duplicate() {
let tmp = setup_with_type();
hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "title"])
.output()
.unwrap();
let show = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "show", "note"])
.output()
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&show.stdout).unwrap();
let required = json["results"]["required"].as_array().unwrap();
let title_count = required
.iter()
.filter(|v| v.as_str() == Some("title"))
.count();
assert_eq!(title_count, 1);
}
#[test]
fn types_set_property_type_string() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--property-type", "status=string"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let toml_content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(toml_content.contains("type = \"string\""));
}
#[test]
fn types_set_property_type_date() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--property-type", "date=date"])
.output()
.unwrap();
assert!(output.status.success());
let toml_content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(toml_content.contains("type = \"date\""));
}
#[test]
fn types_set_property_values_creates_enum() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args([
"types",
"set",
"note",
"--property-values",
"status=draft,published",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let toml_content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(toml_content.contains("type = \"enum\""));
assert!(toml_content.contains("draft"));
assert!(toml_content.contains("published"));
}
#[test]
fn types_set_filename_template() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args([
"types",
"set",
"note",
"--filename-template",
"notes/{slug}.md",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let toml_content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(toml_content.contains("notes/{slug}.md"));
}
#[test]
fn types_set_dry_run_does_not_modify_toml() {
let tmp = setup_with_type();
let before = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "branch", "--dry-run"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let after = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert_eq!(
before, after,
"--dry-run must not modify the .hyalo.toml file"
);
}
#[test]
fn types_set_default_applies_to_matching_files() {
let tmp = setup_empty();
write_md(tmp.path(), "a.md", "---\ntitle: A\ntype: note\n---\nBody.");
write_md(tmp.path(), "b.md", "---\ntitle: B\ntype: other\n---\nBody.");
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--default", "status=draft"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let a_content = fs::read_to_string(tmp.path().join("a.md")).unwrap();
assert!(
a_content.contains("status: draft"),
"expected status: draft in a.md, got:\n{a_content}"
);
let b_content = fs::read_to_string(tmp.path().join("b.md")).unwrap();
assert!(
!b_content.contains("status"),
"b.md should not be modified, got:\n{b_content}"
);
}
#[test]
fn types_set_default_dry_run_does_not_modify_files() {
let tmp = setup_empty();
write_md(tmp.path(), "a.md", "---\ntitle: A\ntype: note\n---\nBody.");
let output = hyalo()
.current_dir(tmp.path())
.args([
"types",
"set",
"note",
"--default",
"status=draft",
"--dry-run",
])
.output()
.unwrap();
assert!(output.status.success());
let a_content = fs::read_to_string(tmp.path().join("a.md")).unwrap();
assert!(
!a_content.contains("status"),
"--dry-run must not write to vault files"
);
}
#[test]
fn types_set_no_flags_exits_nonzero() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note"])
.output()
.unwrap();
assert!(!output.status.success());
}
#[test]
fn types_set_creates_type_when_missing() {
let tmp = setup_empty();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "ghost", "--required", "title"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let list_out = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "list"])
.output()
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&list_out.stdout).unwrap();
assert_eq!(json["total"], 1);
assert_eq!(json["results"][0]["type"], "ghost");
let set_json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(set_json["results"]["action"], "created_and_updated");
}
#[test]
fn types_set_enables_validate_on_write_when_schema_is_new() {
let tmp = setup_empty();
let toml_before = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(
!toml_before.contains("validate_on_write"),
"precondition: no validate_on_write before types set"
);
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "article", "--required", "title"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let toml_after = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(
toml_after.contains("validate_on_write = true"),
"expected validate_on_write = true in .hyalo.toml, got:\n{toml_after}"
);
let output2 = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "title"])
.output()
.unwrap();
assert!(
output2.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output2.stderr)
);
let toml_final = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert_eq!(
toml_final.matches("validate_on_write").count(),
1,
"validate_on_write should appear exactly once, got:\n{toml_final}"
);
assert!(
toml_final.contains("validate_on_write = true"),
"validate_on_write should still be true after second type, got:\n{toml_final}"
);
}
#[test]
fn types_set_dry_run_does_not_write_validate_on_write() {
let tmp = setup_empty();
let output = hyalo()
.current_dir(tmp.path())
.args([
"types",
"set",
"article",
"--required",
"title",
"--dry-run",
])
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let changes = json["results"]["toml_changes"]
.as_array()
.expect("toml_changes is array");
assert!(
changes
.iter()
.any(|c| c.as_str().unwrap().contains("validate_on_write")),
"dry-run output should report validate_on_write, got: {changes:?}"
);
let toml = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(
!toml.contains("validate_on_write"),
"dry-run must not write validate_on_write to disk, got:\n{toml}"
);
}
#[test]
fn types_set_does_not_add_validate_on_write_to_existing_schema() {
let tmp = setup_empty();
fs::write(
tmp.path().join(".hyalo.toml"),
"dir = \".\"\n\n[schema.default]\nrequired = [\"title\"]\n",
)
.unwrap();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "article", "--required", "title"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let toml = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(
!toml.contains("validate_on_write"),
"should not retroactively add validate_on_write to existing schema, got:\n{toml}"
);
}
fn setup_with_rich_type() -> TempDir {
let tmp = setup_empty();
fs::write(
tmp.path().join(".hyalo.toml"),
r#"dir = "."
[schema.types.iteration]
required = ["title", "date", "status"]
filename-template = "iteration-{N}-{slug}.md"
[schema.types.iteration.properties.status]
type = "enum"
values = ["planned", "in-progress", "completed"]
[schema.types.iteration.properties.date]
type = "date"
[schema.types.iteration.defaults]
status = "planned"
"#,
)
.unwrap();
tmp
}
#[test]
fn types_show_format_text_has_indentation() {
let tmp = setup_with_rich_type();
let output = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "show", "iteration", "--format", "text"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let text = String::from_utf8_lossy(&output.stdout);
assert!(
text.contains("Type: iteration"),
"expected 'Type: iteration' header, got:\n{text}"
);
assert!(
text.contains("Required:"),
"expected 'Required:' line, got:\n{text}"
);
assert!(
text.contains("Properties:"),
"expected 'Properties:' section, got:\n{text}"
);
assert!(
text.lines().any(|l| l.starts_with(" status:")),
"expected ' status:' line with 2-space indent, got:\n{text}"
);
assert!(
text.lines().any(|l| l.starts_with(" date:")),
"expected ' date:' line with 2-space indent, got:\n{text}"
);
assert!(
text.lines().any(|l| l.starts_with(" type:")),
"expected ' type:' line with 4-space indent, got:\n{text}"
);
assert!(
text.contains("Defaults:"),
"expected 'Defaults:' section, got:\n{text}"
);
assert!(
text.lines().any(|l| l.starts_with(" status: planned")),
"expected ' status: planned' in Defaults block, got:\n{text}"
);
assert!(
text.contains("Filename template: iteration-{N}-{slug}.md"),
"expected filename template line, got:\n{text}"
);
}
#[test]
fn types_list_format_text_has_type_headers_and_separation() {
let tmp = setup_with_rich_type();
hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "title"])
.output()
.unwrap();
let output = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "list", "--format", "text"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let text = String::from_utf8_lossy(&output.stdout);
assert!(
text.lines().any(|l| l.starts_with("iteration (")),
"expected 'iteration (...)' header line, got:\n{text}"
);
assert!(
text.lines().any(|l| l.starts_with("note (")),
"expected 'note (...)' header line, got:\n{text}"
);
assert!(
text.lines().any(|l| l.starts_with(" required:")),
"expected ' required:' line with 2-space indent, got:\n{text}"
);
assert!(
text.contains("\n\n"),
"expected blank line between type entries, got:\n{text}"
);
}
#[test]
fn types_set_preserves_existing_toml_comments() {
let tmp = setup_empty();
fs::write(
tmp.path().join(".hyalo.toml"),
"# My vault config\ndir = \".\"\n",
)
.unwrap();
hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "title"])
.output()
.unwrap();
let content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(
content.contains("# My vault config"),
"comment should be preserved:\n{content}"
);
}
#[test]
fn types_set_auto_creates_string_properties_for_required() {
let tmp = setup_empty();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "status"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let toml_content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(
toml_content.contains("type = \"string\""),
"expected auto-created string property for 'status', got:\n{toml_content}"
);
let show = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "show", "note"])
.output()
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&show.stdout).unwrap();
let props = &json["results"]["properties"];
assert!(
props.get("status").is_some(),
"expected 'status' in properties, got:\n{props}"
);
}
#[test]
fn types_set_upsert_does_not_duplicate_type() {
let tmp = setup_with_type();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "set", "note", "--required", "status"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let list_out = hyalo_no_hints()
.current_dir(tmp.path())
.args(["types", "list"])
.output()
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&list_out.stdout).unwrap();
assert_eq!(json["total"], 1, "type should appear exactly once");
let set_json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(set_json["results"]["action"], "updated");
}