diff --git a/scripts/install.sh b/scripts/install.sh index 57613a8..37e81d0 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -131,12 +131,21 @@ SKILLS_REPO="https://github.com/UniverLab/skills" if [ -n "${SKIP_SKILL:-}" ]; then info "skill" "skipped (SKIP_SKILL set)" elif command -v npx >/dev/null 2>&1; then - info "skill" "adding '$SKILL' (npx skills add)" - if npx -y skills add "$SKILLS_REPO" --skill "$SKILL" Result<()> { println!("\n✓ Configuration saved!"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn with_temp_config(f: impl FnOnce()) { + let tmp = tempfile::tempdir().unwrap(); + let orig = std::env::var("XDG_CONFIG_HOME").ok(); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + f(); + match orig { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + + #[test] + fn get_name_set_and_retrieve() { + with_temp_config(|| { + set("name", "Alice").unwrap(); + get("name").unwrap(); + }); + } + + #[test] + fn get_email_set_and_retrieve() { + with_temp_config(|| { + set("email", "alice@test.com").unwrap(); + get("email").unwrap(); + }); + } + + #[test] + fn get_institution_set_and_retrieve() { + with_temp_config(|| { + set("institution", "MIT").unwrap(); + get("institution").unwrap(); + }); + } + + #[test] + fn get_language_set_and_retrieve() { + with_temp_config(|| { + set("language", "spanish").unwrap(); + get("language").unwrap(); + }); + } + + #[test] + fn get_unknown_key_errors() { + with_temp_config(|| { + let result = get("unknown"); + assert!(result.is_err()); + }); + } + + #[test] + fn set_unknown_key_errors() { + with_temp_config(|| { + let result = set("unknown", "value"); + assert!(result.is_err()); + }); + } + + #[test] + fn get_unset_shows_not_set() { + with_temp_config(|| { + // name not set, should print "(not set)" + get("name").unwrap(); + }); + } + + #[test] + fn list_displays_all_sections() { + with_temp_config(|| { + set("name", "Bob").unwrap(); + set("email", "bob@test.com").unwrap(); + set("institution", "Stanford").unwrap(); + set("language", "english").unwrap(); + list().unwrap(); + }); + } + + #[test] + fn list_with_unset_values() { + with_temp_config(|| { + // All unset — should print "(not set)" for each + list().unwrap(); + }); + } + + #[test] + fn set_then_get_roundtrip() { + with_temp_config(|| { + set("name", "Test").unwrap(); + get("name").unwrap(); + set("email", "test@test.com").unwrap(); + get("email").unwrap(); + }); + } + + #[test] + fn set_overwrites_existing() { + with_temp_config(|| { + set("name", "First").unwrap(); + set("name", "Second").unwrap(); + get("name").unwrap(); + }); + } +} diff --git a/src/commands/fmt.rs b/src/commands/fmt.rs index b7bfbb1..ed6af5c 100644 --- a/src/commands/fmt.rs +++ b/src/commands/fmt.rs @@ -62,3 +62,69 @@ fn format_one(file: &Path, root: &Path, check: bool, fmt: fn(&str) -> String) -> } Ok(1) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn format_one_no_change_returns_zero() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("test.tex"); + fs::write(&file, "hello\n").unwrap(); + let count = format_one(&file, root, false, |s| s.to_string()).unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn format_one_needs_formatting_returns_one() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("test.tex"); + fs::write(&file, "hello \n").unwrap(); + let count = format_one(&file, root, false, |s| format!("{}\n", s.trim_end())).unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn format_one_check_mode_does_not_write() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("test.tex"); + fs::write(&file, "hello \n").unwrap(); + let count = format_one(&file, root, true, |s| format!("{}\n", s.trim_end())).unwrap(); + assert_eq!(count, 1); + // File should be unchanged + assert_eq!(fs::read_to_string(&file).unwrap(), "hello \n"); + } + + #[test] + fn format_one_check_mode_writes_when_not_checking() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("test.tex"); + fs::write(&file, "hello \n").unwrap(); + let count = format_one(&file, root, false, |s| format!("{}\n", s.trim_end())).unwrap(); + assert_eq!(count, 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "hello\n"); + } + + #[test] + fn execute_no_files_returns_ok() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // Write a project.toml so Project::load() works + fs::write( + root.join("project.toml"), + "[document]\ntitle = \"T\"\nauthor = \"A\"\ntemplate = \"general\"\n\n[build]\nentry = \"main.tex\"\n", + ) + .unwrap(); + let orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(root).unwrap(); + let result = execute(false); + std::env::set_current_dir(&orig).unwrap(); + result.unwrap(); + } +} diff --git a/src/commands/new.rs b/src/commands/new.rs index 879f6ae..0584b42 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -192,4 +192,116 @@ mod tests { fn valid_name_is_ok() { assert!(validate_project_name("mi-tesis").is_ok()); } + + #[test] + fn name_with_backslash_is_error() { + assert!(validate_project_name("a\\b").is_err()); + } + + #[test] + fn name_with_absolute_path_is_error() { + assert!(validate_project_name("/etc/passwd").is_err()); + } + + #[test] + fn name_with_special_char_is_error() { + assert!(validate_project_name("project@name").is_err()); + assert!(validate_project_name("project#1").is_err()); + assert!(validate_project_name("project$").is_err()); + assert!(validate_project_name("project!").is_err()); + assert!(validate_project_name("project&test").is_err()); + assert!(validate_project_name("project|test").is_err()); + assert!(validate_project_name("project;test").is_err()); + assert!(validate_project_name("project`test").is_err()); + assert!(validate_project_name("project\"test").is_err()); + assert!(validate_project_name("project'test").is_err()); + assert!(validate_project_name("project*test").is_err()); + assert!(validate_project_name("project?test").is_err()); + } + + #[test] + fn name_with_only_whitespace_is_error() { + assert!(validate_project_name(" ").is_err()); + assert!(validate_project_name("\t").is_err()); + } + + #[test] + fn apply_substitutions_replaces_tokens() { + let mut values = HashMap::new(); + values.insert("title".to_string(), "My Doc".to_string()); + values.insert("author".to_string(), "Jane".to_string()); + + let content = "\\title{{{title}}}\n\\author{{{author}}}"; + let result = apply_substitutions(content, &values); + assert_eq!(result, "\\title{My Doc}\n\\author{Jane}"); + } + + #[test] + fn apply_substitutions_leaves_unmatched_tokens() { + let values = HashMap::new(); + let content = "\\title{{{title}}}"; + let result = apply_substitutions(content, &values); + assert_eq!(result, "\\title{{{title}}}"); + } + + #[test] + fn apply_substitutions_empty_content() { + let values = HashMap::new(); + let result = apply_substitutions("", &values); + assert_eq!(result, ""); + } + + #[test] + fn apply_substitutions_multiple_same_token() { + let mut values = HashMap::new(); + values.insert("x".to_string(), "Y".to_string()); + let result = apply_substitutions("{{x}} and {{x}}", &values); + assert_eq!(result, "Y and Y"); + } + + #[test] + fn resolve_placeholder_values_empty_files() { + let files = HashMap::new(); + let cli_args = HashMap::new(); + let result = resolve_placeholder_values(&files, cli_args); + assert!(result.is_empty()); + } + + #[test] + fn resolve_placeholder_values_invalid_toml() { + let mut files = HashMap::new(); + files.insert("template.toml".to_string(), b"not valid {{{ toml".to_vec()); + let cli_args = HashMap::new(); + let result = resolve_placeholder_values(&files, cli_args); + assert!(result.is_empty()); + } + + #[test] + fn resolve_placeholder_values_non_utf8() { + let mut files = HashMap::new(); + files.insert("template.toml".to_string(), vec![0xFF, 0xFE]); + let cli_args = HashMap::new(); + let result = resolve_placeholder_values(&files, cli_args); + assert!(result.is_empty()); + } + + #[test] + fn name_with_hyphens_is_ok() { + assert!(validate_project_name("my-tesis-v2").is_ok()); + } + + #[test] + fn name_with_underscores_is_ok() { + assert!(validate_project_name("my_tesis").is_ok()); + } + + #[test] + fn name_with_dots_is_ok() { + assert!(validate_project_name("my.tesis").is_ok()); + } + + #[test] + fn name_with_single_char_is_ok() { + assert!(validate_project_name("a").is_ok()); + } } diff --git a/src/commands/template.rs b/src/commands/template.rs index f7e952b..6804761 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -70,3 +70,52 @@ pub fn validate(name: &str) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn ensure_rustls() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + #[test] + fn validate_general_template() { + // "general" falls back to embedded, which has template.toml + validate("general").unwrap(); + } + + #[test] + fn validate_unknown_template_errors() { + ensure_rustls(); + let result = validate("definitely-not-a-template-xyz"); + assert!(result.is_err()); + } + + #[test] + fn remove_nonexistent_template_errors() { + let result = remove("definitely-not-cached-xyz"); + assert!(result.is_err()); + } + + #[test] + fn list_local_only() { + // include_remote=false should succeed without network + list(false).unwrap(); + } + + #[test] + fn list_cached_returns_installed() { + let cached = templates::list_cached().unwrap(); + // Should be a Vec (possibly empty) + let _ = cached; + } + + #[test] + fn validate_general_has_all_required_files() { + ensure_rustls(); + let resolved = templates::resolve("general").unwrap(); + assert!(resolved.files.contains_key("template.toml")); + assert!(resolved.files.contains_key("main.tex")); + } +} diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index aeb275d..9b67867 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -258,3 +258,192 @@ fn current_target() -> Result<&'static str> { )))] anyhow::bail!("Unsupported platform for automatic tectonic installation") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_errors_tectonic_style() { + let raw = "error: main.tex:42: undefined control sequence \\foo"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].file, "main.tex"); + assert_eq!(errors[0].line, 42); + assert_eq!(errors[0].message, "undefined control sequence \\foo"); + } + + #[test] + fn parse_errors_bang_style() { + let raw = "! Undefined control sequence.\nl.10 \\badcmd"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "Undefined control sequence."); + assert_eq!(errors[0].line, 10); + } + + #[test] + fn parse_errors_bang_no_line() { + let raw = "! Missing $ inserted."; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "Missing $ inserted."); + assert_eq!(errors[0].line, 0); + } + + #[test] + fn parse_errors_multiple() { + let raw = "error: a.tex:1: first error\nerror: b.tex:5: second error"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].file, "a.tex"); + assert_eq!(errors[1].file, "b.tex"); + assert_eq!(errors[1].line, 5); + } + + #[test] + fn parse_errors_empty() { + let errors = parse_errors(""); + assert!(errors.is_empty()); + } + + #[test] + fn parse_errors_unrecognized_line() { + let raw = "some random output\nnot an error"; + let errors = parse_errors(raw); + assert!(errors.is_empty()); + } + + #[test] + fn parse_tectonic_error_with_location() { + let mut errors = Vec::new(); + parse_tectonic_error("main.tex:10: undefined control sequence", &mut errors); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].file, "main.tex"); + assert_eq!(errors[0].line, 10); + assert_eq!(errors[0].message, "undefined control sequence"); + } + + #[test] + fn parse_tectonic_error_without_colon_location() { + let mut errors = Vec::new(); + parse_tectonic_error("some generic message", &mut errors); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].file, ""); + assert_eq!(errors[0].line, 0); + assert_eq!(errors[0].message, "some generic message"); + } + + #[test] + fn parse_tectonic_error_non_numeric_line() { + let mut errors = Vec::new(); + parse_tectonic_error("file.tex:abc: bad", &mut errors); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].file, ""); + assert_eq!(errors[0].line, 0); + } + + #[test] + fn parse_errors_mixed_styles() { + let raw = "error: a.tex:1: first\n! Second error.\nl.20 \\second"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].file, "a.tex"); + assert_eq!(errors[1].line, 20); + } + + #[test] + fn current_target_returns_known_value() { + let target = current_target().unwrap(); + assert!(!target.is_empty()); + assert!(target.contains("linux") || target.contains("macos") || target.contains("windows")); + } + + #[test] + fn find_tectonic_returns_path() { + let result = find_tectonic(); + // This test just verifies the function doesn't panic; + // tectonic may or may not be installed. + if let Ok(path) = result { + assert!(!path.as_os_str().is_empty()); + } + } + + #[test] + fn tectonic_managed_path_returns_home_texforge() { + let result = tectonic_managed_path(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.to_string_lossy().contains(".texforge")); + assert!(path.to_string_lossy().contains("bin")); + } + + #[test] + fn locate_tectonic_returns_option() { + // Should not panic; may or may not find tectonic + let _ = locate_tectonic(); + } + + #[test] + fn parse_errors_tectonic_with_complex_message() { + let raw = "error: main.tex:100: undefined control sequence \\foo\\bar"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].file, "main.tex"); + assert_eq!(errors[0].line, 100); + assert!(errors[0].message.contains("\\foo\\bar")); + } + + #[test] + fn parse_errors_l_line_without_number() { + let raw = "! Error.\nl.abc \\badcmd"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "Error."); + // l.abc doesn't parse as a number, so line stays 0 + assert_eq!(errors[0].line, 0); + } + + #[test] + fn parse_tectonic_error_no_colon_in_rest() { + // "error:" followed by text with no ": " separator + let mut errors = Vec::new(); + parse_tectonic_error("just a message without colon", &mut errors); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "just a message without colon"); + assert_eq!(errors[0].file, ""); + assert_eq!(errors[0].line, 0); + } + + #[test] + fn parse_errors_whitespace_handling() { + let raw = " error: a.tex:5: msg \n ! Another.\n l.10 x"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 2); + assert_eq!(errors[0].file, "a.tex"); + assert_eq!(errors[1].line, 10); + } + + #[test] + fn parse_errors_empty_lines_between() { + let raw = "\n\nerror: f.tex:1: e\n\n\n"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].file, "f.tex"); + } + + #[test] + fn current_target_contains_valid_arch() { + let target = current_target().unwrap(); + assert!(target.contains("x86_64") || target.contains("aarch64") || target.contains("arm")); + } + + #[test] + fn parse_errors_deeply_nested_path() { + let raw = "error: /some/long/path/to/file.tex:42: bad thing"; + let errors = parse_errors(raw); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].file, "/some/long/path/to/file.tex"); + assert_eq!(errors[0].line, 42); + } +} diff --git a/src/config.rs b/src/config.rs index 9ebcf40..196a19f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -135,4 +135,155 @@ fontsize = "11pt" assert!(toml_str.contains("John Doe")); assert!(toml_str.contains("john@example.com")); } + + #[test] + fn test_default_config() { + let config = Config::default(); + assert!(config.user.name.is_none()); + assert!(config.user.email.is_none()); + assert!(config.institution.name.is_none()); + assert!(config.defaults.documentclass.is_none()); + assert!(config.templates.source.is_none()); + } + + #[test] + fn test_parse_institution() { + let toml_str = r#" +[institution] +name = "University" +address = "123 Main St" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.institution.name, Some("University".to_string())); + assert_eq!(config.institution.address, Some("123 Main St".to_string())); + } + + #[test] + fn test_parse_templates_section() { + let toml_str = r#" +[templates] +source = "registry" +auto_update = true +watch = false +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.templates.source, Some("registry".to_string())); + assert_eq!(config.templates.auto_update, Some(true)); + assert_eq!(config.templates.watch, Some(false)); + } + + #[test] + fn test_parse_defaults_all_fields() { + let toml_str = r#" +[defaults] +documentclass = "report" +fontsize = "12pt" +papersize = "a4" +language = "spanish" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.defaults.documentclass, Some("report".to_string())); + assert_eq!(config.defaults.fontsize, Some("12pt".to_string())); + assert_eq!(config.defaults.papersize, Some("a4".to_string())); + assert_eq!(config.defaults.language, Some("spanish".to_string())); + } + + #[test] + fn test_parse_empty_toml() { + let config: Config = toml::from_str("").unwrap(); + let default = Config::default(); + assert_eq!(config.user.name, default.user.name); + assert_eq!(config.user.email, default.user.email); + assert_eq!(config.institution.name, default.institution.name); + assert_eq!( + config.defaults.documentclass, + default.defaults.documentclass + ); + } + + #[test] + fn test_config_file_path_returns_result() { + let result = config_file_path(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.to_string_lossy().contains("texforge")); + assert!(path.to_string_lossy().ends_with("config.toml")); + } + + #[test] + fn test_serialize_roundtrip() { + let mut config = Config::default(); + config.user.name = Some("Test User".to_string()); + config.user.email = Some("test@test.com".to_string()); + config.institution.name = Some("Test Uni".to_string()); + config.defaults.language = Some("english".to_string()); + + let serialized = toml::to_string_pretty(&config).unwrap(); + let deserialized: Config = toml::from_str(&serialized).unwrap(); + assert_eq!(config.user.name, deserialized.user.name); + assert_eq!(config.user.email, deserialized.user.email); + assert_eq!(config.institution.name, deserialized.institution.name); + assert_eq!(config.defaults.language, deserialized.defaults.language); + } + + #[test] + fn test_load_missing_file_returns_default() { + // load() returns Config::default() when file doesn't exist + // We can't easily test the real load() since it uses config_file_path() + // But we can test that a config with no file is equivalent to default + let config = Config::default(); + assert!(config.user.name.is_none()); + assert!(config.user.email.is_none()); + } + + #[test] + fn test_config_serialization_institution_all_fields() { + let mut config = Config::default(); + config.institution.name = Some("MIT".to_string()); + config.institution.address = Some("77 Massachusetts Ave".to_string()); + let toml_str = toml::to_string_pretty(&config).unwrap(); + assert!(toml_str.contains("MIT")); + assert!(toml_str.contains("77 Massachusetts Ave")); + } + + #[test] + fn test_config_templates_fields() { + let mut config = Config::default(); + config.templates.source = Some("github".to_string()); + config.templates.auto_update = Some(true); + config.templates.watch = Some(false); + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.templates.source, Some("github".to_string())); + assert_eq!(parsed.templates.auto_update, Some(true)); + assert_eq!(parsed.templates.watch, Some(false)); + } + + #[test] + fn test_config_defaults_all_fields_serialize() { + let mut config = Config::default(); + config.defaults.documentclass = Some("book".to_string()); + config.defaults.fontsize = Some("10pt".to_string()); + config.defaults.papersize = Some("letter".to_string()); + config.defaults.language = Some("french".to_string()); + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.defaults.documentclass, Some("book".to_string())); + assert_eq!(parsed.defaults.fontsize, Some("10pt".to_string())); + assert_eq!(parsed.defaults.papersize, Some("letter".to_string())); + assert_eq!(parsed.defaults.language, Some("french".to_string())); + } + + #[test] + fn test_config_partial_toml_uses_defaults() { + let toml_str = r#" +[user] +name = "Only Name" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.user.name, Some("Only Name".to_string())); + assert!(config.user.email.is_none()); + assert!(config.institution.name.is_none()); + assert!(config.defaults.documentclass.is_none()); + } } diff --git a/src/domain/project.rs b/src/domain/project.rs index 945d823..a98d6ad 100644 --- a/src/domain/project.rs +++ b/src/domain/project.rs @@ -49,3 +49,120 @@ impl Project { Ok(Self { root, config }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_config_deserialize_full() { + let toml_str = r#" +[document] +title = "My Thesis" +author = "Jane" +template = "general" + +[build] +entry = "main.tex" +bibliography = "refs.bib" +"#; + let config: ProjectConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.document.title, "My Thesis"); + assert_eq!(config.document.author, "Jane"); + assert_eq!(config.document.template, "general"); + assert_eq!(config.build.entry, "main.tex"); + assert_eq!(config.build.bibliography, Some("refs.bib".to_string())); + } + + #[test] + fn project_config_deserialize_no_bibliography() { + let toml_str = r#" +[document] +title = "Doc" +author = "A" +template = "general" + +[build] +entry = "main.tex" +"#; + let config: ProjectConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.build.bibliography, None); + } + + #[test] + fn project_config_serialize_roundtrip() { + let config = ProjectConfig { + document: DocumentConfig { + title: "Test".to_string(), + author: "Author".to_string(), + template: "general".to_string(), + }, + build: BuildConfig { + entry: "main.tex".to_string(), + bibliography: Some("refs.bib".to_string()), + }, + }; + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: ProjectConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.document.title, "Test"); + assert_eq!(parsed.build.entry, "main.tex"); + } + + #[test] + fn project_config_debug_clone() { + let config = ProjectConfig { + document: DocumentConfig { + title: "T".to_string(), + author: "A".to_string(), + template: "general".to_string(), + }, + build: BuildConfig { + entry: "main.tex".to_string(), + bibliography: None, + }, + }; + let cloned = config.clone(); + let debug_str = format!("{:?}", config); + assert!(debug_str.contains("T")); + assert_eq!(cloned.document.title, "T"); + } + + #[test] + fn project_load_no_project_toml_errors() { + let tmp = tempfile::tempdir().unwrap(); + let orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + let result = Project::load(); + std::env::set_current_dir(&orig).unwrap(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No project.toml")); + } + + #[test] + fn project_load_valid_toml() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("project.toml"), + "[document]\ntitle = \"T\"\nauthor = \"A\"\ntemplate = \"general\"\n\n[build]\nentry = \"main.tex\"\n", + ) + .unwrap(); + let orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + let result = Project::load(); + std::env::set_current_dir(&orig).unwrap(); + let project = result.unwrap(); + assert_eq!(project.config.document.title, "T"); + assert_eq!(project.config.build.entry, "main.tex"); + } + + #[test] + fn project_load_invalid_toml_errors() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("project.toml"), "not valid {{{ toml").unwrap(); + let orig = std::env::current_dir().unwrap(); + std::env::set_current_dir(tmp.path()).unwrap(); + let result = Project::load(); + std::env::set_current_dir(&orig).unwrap(); + assert!(result.is_err()); + } +} diff --git a/src/formatter/mod.rs b/src/formatter/mod.rs index f706d46..c2cc73d 100644 --- a/src/formatter/mod.rs +++ b/src/formatter/mod.rs @@ -466,4 +466,179 @@ mod tests { let out = format_bib(src); assert_eq!(out, "@misc{k,\n title = {Hello, World},\n}\n"); } + + #[test] + fn lstlisting_content_preserved() { + let src = "\\begin{lstlisting}\n code here\n\\end{lstlisting}"; + let out = format(src); + assert_eq!(out, "\\begin{lstlisting}\n code here\n\\end{lstlisting}\n"); + } + + #[test] + fn minted_content_preserved() { + let src = "\\begin{minted}\n raw\n\\end{minted}"; + let out = format(src); + assert_eq!(out, "\\begin{minted}\n raw\n\\end{minted}\n"); + } + + #[test] + fn leading_dedent_end() { + assert_eq!(leading_dedent("\\end{doc}"), 1); + } + + #[test] + fn leading_dedent_braces() { + assert_eq!(leading_dedent("}}"), 2); + } + + #[test] + fn leading_dedent_none() { + assert_eq!(leading_dedent("hello"), 0); + } + + #[test] + fn nesting_delta_begin() { + assert_eq!(nesting_delta("\\begin{doc}"), 1); + } + + #[test] + fn nesting_delta_end() { + assert_eq!(nesting_delta("\\end{doc}"), -1); + } + + #[test] + fn nesting_delta_braces() { + assert_eq!(nesting_delta("{ a }"), 0); + } + + #[test] + fn nesting_delta_comment_ignored() { + assert_eq!(nesting_delta("x % { ignore"), 0); + } + + #[test] + fn nesting_delta_escaped_braces() { + assert_eq!(nesting_delta("\\{ \\}"), 0); + } + + #[test] + fn extract_env_name_simple() { + assert_eq!( + extract_env_name("\\begin{figure}"), + Some("figure".to_string()) + ); + } + + #[test] + fn extract_env_name_no_begin() { + assert_eq!(extract_env_name("no begin here"), None); + } + + #[test] + fn count_occurrences_basic() { + assert_eq!(count_occurrences("abcabc", "abc"), 2); + } + + #[test] + fn count_occurrences_none() { + assert_eq!(count_occurrences("hello", "xyz"), 0); + } + + #[test] + fn split_top_level_simple() { + let result = split_top_level("a, b, c"); + assert_eq!(result, vec!["a", " b", " c"]); + } + + #[test] + fn split_top_level_braced_comma() { + let result = split_top_level("a={x,y}, b"); + assert_eq!(result, vec!["a={x,y}", " b"]); + } + + #[test] + fn split_top_level_quoted_comma() { + let result = split_top_level("\"a,b\", c"); + assert_eq!(result, vec!["\"a,b\"", " c"]); + } + + #[test] + fn top_level_eq_found() { + assert_eq!(top_level_eq("name = value"), Some(5)); + } + + #[test] + fn top_level_eq_in_braces() { + assert_eq!(top_level_eq("{name = value}"), None); + } + + #[test] + fn collapse_ws_basic() { + assert_eq!(collapse_ws(" hello world "), "hello world"); + } + + #[test] + fn collapse_ws_newlines() { + assert_eq!(collapse_ws("a\n\nb"), "a b"); + } + + #[test] + fn format_empty_lines_only() { + let out = format("\n\n\n"); + assert_eq!(out, "\n"); + } + + #[test] + fn format_single_line() { + let out = format("hello"); + assert_eq!(out, "hello\n"); + } + + #[test] + fn format_mixed_content() { + let src = "\\begin{document}\n\\begin{figure}\nimg\n\\end{figure}\n\\end{document}"; + let out = format(src); + assert!(out.contains(" \\begin{figure}")); + assert!(out.contains(" img")); + assert!(out.contains(" \\end{figure}")); + } + + #[test] + fn bib_string_entry() { + let src = "@string{jabref = {Journal of Things}}"; + let out = format_bib(src); + assert!(out.contains("@string")); + } + + #[test] + fn bib_paren_delimiters() { + let src = "@article(key, author={A. B.})"; + let out = format_bib(src); + assert!(out.contains("@article")); + } + + #[test] + fn bib_unterminated_returns_original() { + let src = "@article{key, author={A. B.}"; + assert_eq!(format_bib(src), src); + } + + #[test] + fn bib_empty_returns_original() { + assert_eq!(format_bib(""), ""); + } + + #[test] + fn format_trailing_whitespace_trimmed() { + let src = "hello \nworld"; + let out = format(src); + assert!(!out.contains(" \n")); + } + + #[test] + fn format_comment_not_counted() { + let src = "\\begin{doc}\nx % { unmatched\n\\end{doc}"; + let out = format(src); + assert_eq!(out, "\\begin{doc}\n x % { unmatched\n\\end{doc}\n"); + } } diff --git a/src/linter/mod.rs b/src/linter/mod.rs index 08669d1..28444ba 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -646,4 +646,168 @@ mod tests { let errors = lint(dir.path(), &entry, None).unwrap(); assert!(has_error(&errors, "without matching \\end{d2}")); } + + #[test] + fn entry_not_exists_is_error() { + let dir = TempDir::new().unwrap(); + let errors = lint(dir.path(), "nonexistent.tex", None).unwrap(); + assert!(has_error(&errors, "does not exist")); + } + + #[test] + fn input_missing_file_is_error() { + let (dir, entry) = setup("\\input{missing}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "missing")); + } + + #[test] + fn input_existing_file_no_error() { + let (dir, entry) = setup("\\input{chapter1}"); + fs::write(dir.path().join("chapter1.tex"), "").unwrap(); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "chapter1")); + } + + #[test] + fn begin_end_matched_no_error() { + let (dir, entry) = setup("\\begin{figure}\n\\end{figure}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "never closed")); + } + + #[test] + fn end_without_begin_is_error() { + let (dir, entry) = setup("\\end{figure}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "without matching \\begin")); + } + + #[test] + fn mismatched_end_is_error() { + let (dir, entry) = setup("\\begin{figure}\n\\end{table}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "does not match")); + } + + #[test] + fn comment_not_linted() { + let (dir, entry) = setup("% \\includegraphics{missing.png}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "missing.png")); + } + + #[test] + fn cite_no_bib_file_no_error() { + let (dir, entry) = setup("\\cite{anything}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "anything")); + } + + #[test] + fn mermaid_valid_pos_no_error() { + let (dir, entry) = setup("\\begin{mermaid}[pos=H]\n\\end{mermaid}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "invalid pos")); + } + + #[test] + fn graphviz_valid_pos_no_error() { + let (dir, entry) = setup("\\begin{graphviz}[pos=t]\n\\end{graphviz}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "invalid pos")); + } + + #[test] + fn d2_valid_pos_no_error() { + let (dir, entry) = setup("\\begin{d2}[pos=b]\n\\end{d2}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "invalid pos")); + } + + #[test] + fn lstinputlisting_existing_file_no_error() { + let (dir, entry) = setup("\\lstinputlisting{code.py}"); + fs::write(dir.path().join("code.py"), "").unwrap(); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "code.py")); + } + + #[test] + fn inputminted_existing_file_no_error() { + let (dir, entry) = setup("\\inputminted{python}{code.py}"); + fs::write(dir.path().join("code.py"), "").unwrap(); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "code.py")); + } + + #[test] + fn ref_with_matching_label_no_error() { + let (dir, entry) = setup("\\label{fig:test}\n\\ref{fig:test}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "fig:test")); + } + + #[test] + fn includegraphics_with_options() { + let (dir, entry) = setup("\\includegraphics[width=0.5\\textwidth]{img.png}"); + fs::write(dir.path().join("img.png"), b"").unwrap(); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "img.png")); + } + + #[test] + fn cite_multiple_keys() { + let (dir, entry) = setup("\\cite{key1,key2}"); + fs::write( + dir.path().join("refs.bib"), + "@article{key1,}\n@article{key2,}", + ) + .unwrap(); + let errors = lint(dir.path(), &entry, Some("refs.bib")).unwrap(); + assert!(!has_error(&errors, "key1")); + assert!(!has_error(&errors, "key2")); + } + + #[test] + fn mixed_errors_and_valid() { + let tex = "\\cite{missing}\n\\includegraphics{img.png}"; + let (dir, entry) = setup(tex); + fs::write(dir.path().join("img.png"), b"").unwrap(); + fs::write(dir.path().join("refs.bib"), "").unwrap(); + let errors = lint(dir.path(), &entry, Some("refs.bib")).unwrap(); + assert!(has_error(&errors, "missing")); + assert!(!has_error(&errors, "img.png")); + } + + #[test] + fn nested_begin_end() { + let (dir, entry) = + setup("\\begin{document}\n\\begin{figure}\n\\end{figure}\n\\end{document}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(errors.is_empty()); + } + + #[test] + fn unclosed_inner_environment() { + let (dir, entry) = setup("\\begin{document}\n\\begin{figure}\n\\end{document}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "never closed")); + } + + #[test] + fn input_with_tex_extension() { + let (dir, entry) = setup("\\input{chapter1.tex}"); + fs::write(dir.path().join("chapter1.tex"), "").unwrap(); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(!has_error(&errors, "chapter1")); + } + + #[test] + fn empty_project_no_errors() { + let (dir, entry) = + setup("\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(errors.is_empty()); + } } diff --git a/src/manifest.rs b/src/manifest.rs index 254a772..ffcee18 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -221,4 +221,212 @@ description = "Duplicate" assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("duplicate")); } + + #[test] + fn test_empty_id_is_error() { + let toml = r#" +id = "" +version = "1.0.0" +display_name = "Test" +description = "Test" +"#; + assert!(TemplateManifest::from_str(toml).is_err()); + } + + #[test] + fn test_empty_version_is_error() { + let toml = r#" +id = "test" +version = "" +display_name = "Test" +description = "Test" +"#; + assert!(TemplateManifest::from_str(toml).is_err()); + } + + #[test] + fn test_empty_display_name_is_error() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "" +description = "Test" +"#; + assert!(TemplateManifest::from_str(toml).is_err()); + } + + #[test] + fn test_empty_placeholder_name_is_error() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "" +type = "string" +description = "Empty name" +"#; + assert!(TemplateManifest::from_str(toml).is_err()); + } + + #[test] + fn test_invalid_toml_is_error() { + let result = TemplateManifest::from_str("not valid {{{ toml"); + assert!(result.is_err()); + } + + #[test] + fn test_get_placeholder_not_found() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + assert!(manifest.get_placeholder("nonexistent").is_none()); + } + + #[test] + fn test_required_placeholders() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "title" +type = "string" +description = "Title" +required = true + +[[placeholders]] +name = "author" +type = "string" +description = "Author" +required = false +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + let required = manifest.required_placeholders(); + assert_eq!(required.len(), 1); + assert_eq!(required[0].name, "title"); + } + + #[test] + fn test_enum_placeholder_with_choices() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "language" +type = "enum" +description = "Document language" +choices = ["english", "spanish"] +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + let ph = manifest.get_placeholder("language").unwrap(); + assert_eq!(ph.r#type, PlaceholderType::Enum); + assert_eq!(ph.choices.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_boolean_placeholder() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "draft" +type = "boolean" +description = "Draft mode" +default = "true" +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + let ph = manifest.get_placeholder("draft").unwrap(); + assert_eq!(ph.r#type, PlaceholderType::Boolean); + } + + #[test] + fn test_multiple_placeholders() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "a" +type = "string" +description = "A" + +[[placeholders]] +name = "b" +type = "string" +description = "B" + +[[placeholders]] +name = "c" +type = "string" +description = "C" +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + assert_eq!(manifest.placeholders.len(), 3); + assert!(manifest.get_placeholder("a").is_some()); + assert!(manifest.get_placeholder("b").is_some()); + assert!(manifest.get_placeholder("c").is_some()); + } + + #[test] + fn test_file_spec_defaults() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + assert!(manifest.files.include.is_empty()); + assert!(manifest.files.exclude.is_empty()); + } + + #[test] + fn test_post_generate_defaults() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + assert!(manifest.post_generate.is_empty()); + } + + #[test] + fn test_serialize_roundtrip() { + let toml = r#" +id = "test" +version = "1.0.0" +display_name = "Test" +description = "Test" + +[[placeholders]] +name = "title" +type = "string" +description = "Title" +required = true +"#; + let manifest = TemplateManifest::from_str(toml).unwrap(); + let serialized = toml::to_string(&manifest).unwrap(); + let deserialized = TemplateManifest::from_str(&serialized).unwrap(); + assert_eq!(manifest.id, deserialized.id); + assert_eq!(manifest.placeholders.len(), deserialized.placeholders.len()); + } } diff --git a/src/placeholders.rs b/src/placeholders.rs index 5901a57..3d0432f 100644 --- a/src/placeholders.rs +++ b/src/placeholders.rs @@ -288,4 +288,312 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn test_resolve_project_config_priority() { + let mut project_config = HashMap::new(); + project_config.insert("title".to_string(), "From Project".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config, + user_config: None, + }; + + let ph = make_placeholder("title", true); + let result = resolver.resolve(&ph).unwrap(); + assert_eq!(result, Some("From Project".to_string())); + } + + #[test] + fn test_resolve_default_value() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let mut ph = make_placeholder("title", false); + ph.default = Some("Default Title".to_string()); + + let result = resolver.resolve(&ph).unwrap(); + assert_eq!(result, Some("Default Title".to_string())); + } + + #[test] + fn test_resolve_optional_not_found() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let ph = make_placeholder("title", false); + let result = resolver.resolve(&ph).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_all_with_optional_skipped() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let mut required = make_placeholder("title", true); + required.default = Some("Default".to_string()); + let optional = make_placeholder("author", false); + + let result = resolver.resolve_all(&[required, optional]).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result.get("title"), Some(&"Default".to_string())); + } + + #[test] + fn test_resolve_all_multiple_required() { + let mut cli_args = HashMap::new(); + cli_args.insert("a".to_string(), "1".to_string()); + cli_args.insert("b".to_string(), "2".to_string()); + + let resolver = PlaceholderResolver { + cli_args, + project_config: HashMap::new(), + user_config: None, + }; + + let ph_a = make_placeholder("a", true); + let ph_b = make_placeholder("b", true); + let result = resolver.resolve_all(&[ph_a, ph_b]).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result.get("a"), Some(&"1".to_string())); + assert_eq!(result.get("b"), Some(&"2".to_string())); + } + + #[test] + fn test_substitute_no_tokens() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let values = HashMap::new(); + let content = "plain text without tokens"; + let result = resolver.substitute(content, &values).unwrap(); + assert_eq!(result, "plain text without tokens"); + } + + #[test] + fn test_flatten_toml_string() { + let mut table = toml::Table::new(); + table.insert("key".to_string(), toml::Value::String("value".to_string())); + let mut result = HashMap::new(); + flatten_toml(&table, "", &mut result); + assert_eq!(result.get("key"), Some(&"value".to_string())); + } + + #[test] + fn test_flatten_toml_nested() { + let mut inner = toml::Table::new(); + inner.insert( + "nested".to_string(), + toml::Value::String("deep".to_string()), + ); + let mut table = toml::Table::new(); + table.insert("section".to_string(), toml::Value::Table(inner)); + let mut result = HashMap::new(); + flatten_toml(&table, "", &mut result); + assert_eq!(result.get("section.nested"), Some(&"deep".to_string())); + } + + #[test] + fn test_flatten_toml_boolean() { + let mut table = toml::Table::new(); + table.insert("flag".to_string(), toml::Value::Boolean(true)); + let mut result = HashMap::new(); + flatten_toml(&table, "", &mut result); + assert_eq!(result.get("flag"), Some(&"true".to_string())); + } + + #[test] + fn test_flatten_toml_with_prefix() { + let mut table = toml::Table::new(); + table.insert("key".to_string(), toml::Value::String("val".to_string())); + let mut result = HashMap::new(); + flatten_toml(&table, "pre", &mut result); + assert_eq!(result.get("pre.key"), Some(&"val".to_string())); + } + + #[test] + fn test_resolve_interpolation_user_name() { + let mut user_config = config::Config::default(); + user_config.user.name = Some("Alice".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = resolver.resolve_interpolations("{{user.name}}").unwrap(); + assert_eq!(result, "Alice"); + } + + #[test] + fn test_resolve_interpolation_user_email() { + let mut user_config = config::Config::default(); + user_config.user.email = Some("alice@test.com".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = resolver.resolve_interpolations("{{user.email}}").unwrap(); + assert_eq!(result, "alice@test.com"); + } + + #[test] + fn test_resolve_interpolation_institution() { + let mut user_config = config::Config::default(); + user_config.institution.name = Some("MIT".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = resolver + .resolve_interpolations("{{institution.name}}") + .unwrap(); + assert_eq!(result, "MIT"); + } + + #[test] + fn test_resolve_interpolation_no_config() { + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: None, + }; + + let result = resolver.resolve_interpolations("{{user.name}}").unwrap(); + assert_eq!(result, "{{user.name}}"); + } + + #[test] + fn test_resolve_from_user_config_author() { + let mut user_config = config::Config::default(); + user_config.user.name = Some("Bob".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = + resolver.resolve_from_user_config(resolver.user_config.as_ref().unwrap(), "author"); + assert_eq!(result, Some("Bob".to_string())); + } + + #[test] + fn test_resolve_from_user_config_email() { + let mut user_config = config::Config::default(); + user_config.user.email = Some("bob@test.com".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = + resolver.resolve_from_user_config(resolver.user_config.as_ref().unwrap(), "email"); + assert_eq!(result, Some("bob@test.com".to_string())); + } + + #[test] + fn test_resolve_from_user_config_institution() { + let mut user_config = config::Config::default(); + user_config.institution.name = Some("Stanford".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = resolver + .resolve_from_user_config(resolver.user_config.as_ref().unwrap(), "institution"); + assert_eq!(result, Some("Stanford".to_string())); + } + + #[test] + fn test_resolve_from_user_config_documentclass() { + let mut user_config = config::Config::default(); + user_config.defaults.documentclass = Some("report".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = resolver + .resolve_from_user_config(resolver.user_config.as_ref().unwrap(), "documentclass"); + assert_eq!(result, Some("report".to_string())); + } + + #[test] + fn test_resolve_from_user_config_language() { + let mut user_config = config::Config::default(); + user_config.defaults.language = Some("spanish".to_string()); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = + resolver.resolve_from_user_config(resolver.user_config.as_ref().unwrap(), "language"); + assert_eq!(result, Some("spanish".to_string())); + } + + #[test] + fn test_resolve_from_user_config_unknown_key() { + let user_config = config::Config::default(); + + let resolver = PlaceholderResolver { + cli_args: HashMap::new(), + project_config: HashMap::new(), + user_config: Some(user_config), + }; + + let result = resolver + .resolve_from_user_config(resolver.user_config.as_ref().unwrap(), "unknown_key"); + assert_eq!(result, None); + } + + #[test] + fn test_cli_overrides_project_config() { + let mut cli_args = HashMap::new(); + cli_args.insert("title".to_string(), "CLI Value".to_string()); + let mut project_config = HashMap::new(); + project_config.insert("title".to_string(), "Project Value".to_string()); + + let resolver = PlaceholderResolver { + cli_args, + project_config, + user_config: None, + }; + + let ph = make_placeholder("title", true); + let result = resolver.resolve(&ph).unwrap(); + assert_eq!(result, Some("CLI Value".to_string())); + } } diff --git a/src/templates/mod.rs b/src/templates/mod.rs index b18ae9e..cb98174 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -211,3 +211,175 @@ pub fn remove_cached(name: &str) -> Result { std::fs::remove_dir_all(&dir)?; Ok(dir) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn ensure_rustls() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + #[test] + fn embedded_general_has_required_files() { + let t = embedded_general(); + assert!(t.files.contains_key("template.toml")); + assert!(t.files.contains_key("main.tex")); + assert!(t.files.contains_key("sections/body.tex")); + assert!(t.files.contains_key("bib/references.bib")); + } + + #[test] + fn embedded_general_main_tex_is_valid_utf8() { + let t = embedded_general(); + let main = t.files.get("main.tex").unwrap(); + let text = std::str::from_utf8(main).expect("main.tex should be valid UTF-8"); + assert!(text.contains("\\documentclass")); + } + + #[test] + fn embedded_general_template_toml_is_valid_toml() { + let t = embedded_general(); + let toml_bytes = t.files.get("template.toml").unwrap(); + let text = std::str::from_utf8(toml_bytes).unwrap(); + let parsed: toml::Value = toml::from_str(text).expect("template.toml should be valid TOML"); + assert!(parsed.is_table()); + } + + #[test] + fn embedded_general_body_tex_not_empty() { + let t = embedded_general(); + let body = t.files.get("sections/body.tex").unwrap(); + assert!(!body.is_empty()); + } + + #[test] + fn embedded_general_references_bib_not_empty() { + let t = embedded_general(); + let bib = t.files.get("bib/references.bib").unwrap(); + assert!(!bib.is_empty()); + } + + #[test] + fn resolve_general_returns_embedded() { + let t = embedded_general(); + assert!(t.files.contains_key("main.tex")); + } + + #[test] + fn list_cached_returns_vec() { + let result = list_cached(); + assert!(result.is_ok()); + } + + #[test] + fn remove_cached_nonexistent_fails() { + let result = remove_cached("definitely-not-cached-xyz-123"); + assert!(result.is_err()); + } + + #[test] + fn embedded_files_count_is_four() { + let t = embedded_general(); + assert_eq!(t.files.len(), 4); + } + + #[test] + fn resolve_unknown_template_errors() { + ensure_rustls(); + let result = resolve("nonexistent-template-xyz-123"); + assert!(result.is_err()); + // Verify the error message mentions the template name + if let Err(e) = result { + let msg = format!("{}", e); + assert!(msg.contains("not found")); + } + } + + #[test] + fn load_dir_recursive_with_files() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + fs::write(base.join("main.tex"), "\\documentclass{article}").unwrap(); + let sub = base.join("sections"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("body.tex"), "Hello").unwrap(); + fs::write(base.join("refs.bib"), "@misc{a}").unwrap(); + + let result = load_dir_recursive(base).unwrap(); + assert!(result.files.contains_key("main.tex")); + assert!(result.files.contains_key("sections/body.tex")); + assert!(result.files.contains_key("refs.bib")); + assert_eq!(result.files.len(), 3); + } + + #[test] + fn load_dir_recursive_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let result = load_dir_recursive(tmp.path()).unwrap(); + assert!(result.files.is_empty()); + } + + #[test] + fn load_dir_recursive_file_contents_match() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("a.tex"), "content_a").unwrap(); + let result = load_dir_recursive(tmp.path()).unwrap(); + let content = result.files.get("a.tex").unwrap(); + assert_eq!(content, b"content_a"); + } + + #[test] + fn list_cached_empty_when_no_templates() { + // Just verify it doesn't panic; the real dir may or may not have templates + let result = list_cached(); + assert!(result.is_ok()); + let _ = result.unwrap(); + } + + #[test] + fn list_cached_finds_cached_templates() { + // Verify the function returns a sorted list + let result = list_cached().unwrap(); + // Check it's sorted + for w in result.windows(2) { + assert!(w[0] <= w[1]); + } + } + + #[test] + fn remove_cached_removes_existing() { + // Create a temp template in the real templates dir, then remove it + let templates_dir = crate::utils::templates_dir().unwrap(); + let test_dir = templates_dir.join("__test_remove_temp__"); + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::write(test_dir.join("x.tex"), "x").unwrap(); + let path = remove_cached("__test_remove_temp__").unwrap(); + assert!(path.ends_with("__test_remove_temp__")); + assert!(!test_dir.exists()); + } + + #[test] + fn load_from_cache_nonexistent_errors() { + let result = load_from_cache("no-such-template-xyz-abc"); + assert!(result.is_err()); + } + + #[test] + fn embedded_general_toml_content_is_nonempty() { + let t = embedded_general(); + let toml = t.files.get("template.toml").unwrap(); + assert!(!toml.is_empty()); + let text = std::str::from_utf8(toml).unwrap(); + assert!(text.contains("template")); + } + + #[test] + fn list_cached_nonexistent_dir_returns_empty() { + // If templates dir doesn't exist, list_cached should return empty + // But utils::templates_dir() creates the dir, so we just test the function + let result = list_cached(); + assert!(result.is_ok()); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 386fcfd..ef44f86 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -191,4 +191,131 @@ mod tests { assert!(!build.path().join("build").exists()); assert!(!build.path().join(".hidden").exists()); } + + #[test] + fn sanitize_empty_string() { + assert_eq!(sanitize_filename(""), ""); + } + + #[test] + fn sanitize_only_special_chars() { + assert_eq!(sanitize_filename("@#$!"), ""); + } + + #[test] + fn sanitize_hyphens_preserved() { + assert_eq!(sanitize_filename("my-tesis"), "my-tesis"); + } + + #[test] + fn sanitize_underscores_converted() { + assert_eq!(sanitize_filename("my_tesis"), "my-tesis"); + } + + #[test] + fn sanitize_leading_trailing_hyphens() { + assert_eq!(sanitize_filename("-hello-"), "hello"); + } + + #[test] + fn sanitize_multiple_consecutive_special() { + assert_eq!(sanitize_filename("a:::b"), "a-b"); + } + + #[test] + fn find_tex_files_finds_tex() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("main.tex"), "").unwrap(); + std::fs::write(dir.path().join("readme.md"), "").unwrap(); + let files = find_tex_files(dir.path()).unwrap(); + assert_eq!(files.len(), 1); + assert!(files[0].to_string_lossy().contains("main.tex")); + } + + #[test] + fn find_tex_files_excludes_build() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("build")).unwrap(); + std::fs::write(dir.path().join("build/main.tex"), "").unwrap(); + std::fs::write(dir.path().join("main.tex"), "").unwrap(); + let files = find_tex_files(dir.path()).unwrap(); + assert_eq!(files.len(), 1); + } + + #[test] + fn find_bib_files_finds_bib() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("refs.bib"), "").unwrap(); + std::fs::write(dir.path().join("main.tex"), "").unwrap(); + let files = find_bib_files(dir.path()).unwrap(); + assert_eq!(files.len(), 1); + assert!(files[0].to_string_lossy().contains("refs.bib")); + } + + #[test] + fn find_bib_files_excludes_build() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("build")).unwrap(); + std::fs::write(dir.path().join("build/refs.bib"), "").unwrap(); + std::fs::write(dir.path().join("refs.bib"), "").unwrap(); + let files = find_bib_files(dir.path()).unwrap(); + assert_eq!(files.len(), 1); + } + + #[test] + fn find_tex_files_nested() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("chapters"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("ch1.tex"), "").unwrap(); + std::fs::write(sub.join("ch2.tex"), "").unwrap(); + let files = find_tex_files(dir.path()).unwrap(); + assert_eq!(files.len(), 2); + } + + #[test] + fn data_dir_returns_path() { + let result = data_dir(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.to_string_lossy().contains(".texforge")); + } + + #[test] + fn templates_dir_returns_path() { + let result = templates_dir(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.to_string_lossy().contains("templates")); + } + + #[test] + fn mirror_assets_empty_dir() { + let src = tempfile::tempdir().unwrap(); + let build = tempfile::tempdir().unwrap(); + mirror_assets(src.path(), build.path()).unwrap(); + assert!(std::fs::read_dir(build.path()).unwrap().next().is_none()); + } + + #[test] + fn mirror_assets_skips_tex_files() { + let src = tempfile::tempdir().unwrap(); + let build = tempfile::tempdir().unwrap(); + std::fs::write(src.path().join("main.tex"), "").unwrap(); + mirror_assets(src.path(), build.path()).unwrap(); + assert!(!build.path().join("main.tex").exists()); + } + + #[test] + fn mirror_assets_nested_dirs() { + let src = tempfile::tempdir().unwrap(); + let build = tempfile::tempdir().unwrap(); + let sub = src.path().join("a").join("b"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("file.txt"), "content").unwrap(); + mirror_assets(src.path(), build.path()).unwrap(); + let dest = build.path().join("a/b/file.txt"); + assert!(dest.exists()); + assert_eq!(std::fs::read_to_string(dest).unwrap(), "content"); + } } diff --git a/src/version.rs b/src/version.rs index 0343ec4..9abaa2c 100644 --- a/src/version.rs +++ b/src/version.rs @@ -168,4 +168,65 @@ mod tests { let v2 = SemVer::parse("1.2.3").unwrap(); assert_eq!(v1, v2); } + + #[test] + fn test_display_simple() { + let v = SemVer::parse("1.2.3").unwrap(); + assert_eq!(v.to_string(), "1.2.3"); + } + + #[test] + fn test_display_prerelease() { + let v = SemVer::parse("1.0.0-alpha").unwrap(); + assert_eq!(v.to_string(), "1.0.0-alpha"); + } + + #[test] + fn test_display_with_v_prefix() { + let v = SemVer::parse("v2.0.1").unwrap(); + assert_eq!(v.to_string(), "2.0.1"); + } + + #[test] + fn test_parse_empty_string() { + assert!(SemVer::parse("").is_none()); + } + + #[test] + fn test_parse_whitespace() { + assert!(SemVer::parse(" ").is_none()); + } + + #[test] + fn test_parse_non_numeric() { + assert!(SemVer::parse("1.2.x").is_none()); + } + + #[test] + fn test_ord_less_than() { + let a = SemVer::parse("1.0.0").unwrap(); + let b = SemVer::parse("2.0.0").unwrap(); + assert!(a < b); + } + + #[test] + fn test_ord_greater_than() { + let a = SemVer::parse("3.0.0").unwrap(); + let b = SemVer::parse("1.0.0").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ord_equal() { + let a = SemVer::parse("1.2.3").unwrap(); + let b = SemVer::parse("1.2.3").unwrap(); + assert!(a == b); + } + + #[test] + fn test_ord_prerelease_less_than_stable() { + let pre = SemVer::parse("1.0.0-alpha").unwrap(); + let stable = SemVer::parse("1.0.0").unwrap(); + assert!(pre < stable); + } } diff --git a/src/version_checker.rs b/src/version_checker.rs index 78cd060..2c25423 100644 --- a/src/version_checker.rs +++ b/src/version_checker.rs @@ -138,4 +138,127 @@ mod tests { let version = get_local_version().unwrap(); assert!(version.major > 0 || version.minor > 0 || version.patch > 0); } + + #[test] + fn test_get_release_download_url() { + let version = SemVer::parse("1.2.3").unwrap(); + let url = get_release_download_url("UniverLab", "texforge", &version); + assert!(url.contains("github.com")); + assert!(url.contains("UniverLab")); + assert!(url.contains("texforge")); + assert!(url.contains("1.2.3")); + } + + #[test] + fn test_get_architecture() { + let arch = get_architecture(); + assert!(!arch.is_empty()); + assert!(arch == "x86_64" || arch == "aarch64" || arch == "arm"); + } + + #[test] + fn test_get_os() { + let os = get_os(); + assert!(!os.is_empty()); + assert!(os == "linux" || os == "macos" || os == "windows" || os == "unknown"); + } + + #[test] + fn test_version_check_result_struct() { + let local = SemVer::parse("1.0.0").unwrap(); + let latest = SemVer::parse("2.0.0").unwrap(); + let result = VersionCheckResult { + local_version: local.clone(), + latest_stable: Some(latest.clone()), + update_available: true, + }; + assert_eq!(result.local_version, local); + assert_eq!(result.latest_stable, Some(latest)); + assert!(result.update_available); + } + + #[test] + fn test_version_check_no_update() { + let local = SemVer::parse("2.0.0").unwrap(); + let latest = SemVer::parse("1.0.0").unwrap(); + let result = VersionCheckResult { + local_version: local, + latest_stable: Some(latest), + update_available: false, + }; + assert!(!result.update_available); + } + + #[test] + fn test_version_check_no_latest() { + let local = SemVer::parse("1.0.0").unwrap(); + let result = VersionCheckResult { + local_version: local, + latest_stable: None, + update_available: false, + }; + assert!(!result.update_available); + assert!(result.latest_stable.is_none()); + } + + #[test] + fn test_get_release_download_url_contains_arch() { + let version = SemVer::parse("1.0.0").unwrap(); + let url = get_release_download_url("owner", "repo", &version); + let arch = get_architecture(); + assert!(url.contains(arch)); + } + + #[test] + fn test_get_release_download_url_format() { + let version = SemVer::parse("2.5.1").unwrap(); + let url = get_release_download_url("UniverLab", "texforge", &version); + assert!(url.starts_with("https://github.com/")); + assert!(url.contains("v2.5.1")); + assert!(url.contains("texforge")); + } + + #[test] + fn test_get_local_version_is_stable() { + let version = get_local_version().unwrap(); + // CARGO_PKG_VERSION should be a stable release (no prerelease) + assert!(version.is_stable()); + } + + #[test] + fn test_get_architecture_nonempty() { + let arch = get_architecture(); + assert!(!arch.is_empty()); + } + + #[test] + fn test_get_os_nonempty() { + let os = get_os(); + assert!(!os.is_empty()); + } + + #[test] + fn test_version_check_result_debug() { + let local = SemVer::parse("1.0.0").unwrap(); + let result = VersionCheckResult { + local_version: local, + latest_stable: None, + update_available: false, + }; + let debug = format!("{:?}", result); + assert!(debug.contains("VersionCheckResult")); + } + + #[test] + fn test_version_check_result_clone() { + let local = SemVer::parse("1.0.0").unwrap(); + let result = VersionCheckResult { + local_version: local, + latest_stable: None, + update_available: false, + }; + let cloned = result.clone(); + assert_eq!(result.local_version, cloned.local_version); + assert_eq!(result.update_available, cloned.update_available); + } }