Skip to content
21 changes: 15 additions & 6 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" </dev/null; then
info "skill" "installed"
else
info "skill" "skipped — add later with: npx skills add $SKILLS_REPO --skill $SKILL"
fi
printf '\n \033[1;36m?\033[0m Install the \033[1m%s\033[0m agent skill? (teaches AI agents how to use %s) [Y/n] ' "$SKILL" "$SKILL"
read -r ANSWER </dev/tty
case "$ANSWER" in
[nN]|[nN][oO])
info "skill" "skipped — add later with: npx skills add $SKILLS_REPO --skill $SKILL"
;;
*)
info "skill" "adding '$SKILL' (npx skills add)"
if npx -y skills add "$SKILLS_REPO" --skill "$SKILL"; then
info "skill" "installed"
else
info "skill" "skipped — add later with: npx skills add $SKILLS_REPO --skill $SKILL"
fi
;;
esac
else
info "skill" "npx not found — add later with: npx skills add $SKILLS_REPO --skill $SKILL"
fi
Expand Down
110 changes: 110 additions & 0 deletions src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,113 @@ pub fn wizard() -> 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();
});
}
}
66 changes: 66 additions & 0 deletions src/commands/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
112 changes: 112 additions & 0 deletions src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
49 changes: 49 additions & 0 deletions src/commands/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
Loading
Loading