use semver::{Version, VersionReq};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn load_github_token() -> Option<String> {
if let Ok(tok) = std::env::var("GITHUB_TOKEN") {
if !tok.is_empty() {
return Some(tok);
}
}
let home = home_dir()?;
let creds_path = home.join(".ilo").join("credentials");
let text = std::fs::read_to_string(&creds_path).ok()?;
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
let tok = val.get("github_token")?.as_str()?;
if tok.is_empty() {
None
} else {
Some(tok.to_string())
}
}
fn github_clone_url(owner: &str, repo: &str, token: Option<&str>) -> String {
match token {
Some(tok) => format!("https://{}@github.com/{owner}/{repo}.git", tok),
None => format!("https://github.com/{owner}/{repo}.git"),
}
}
pub fn pkg_dir_for(owner: &str, repo: &str) -> Option<PathBuf> {
let home = home_dir()?;
Some(home.join(".ilo").join("pkgs").join(owner).join(repo))
}
pub fn parse_package_spec(spec: &str) -> Option<(&str, &str, Option<&str>)> {
let spec = spec.strip_prefix("https://github.com/").unwrap_or(spec);
let (slug, git_ref) = if let Some((s, r)) = spec.split_once('@') {
(s, Some(r))
} else {
(spec, None)
};
let (owner, repo) = slug.split_once('/')?;
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return None;
}
Some((owner, repo, git_ref))
}
pub fn is_pkg_path(path: &str) -> bool {
if path.starts_with('.') || path.starts_with('/') {
return false;
}
let first = path.split('/').next().unwrap_or("");
!first.is_empty() && !first.contains('.')
}
pub fn resolve_pkg_path(path: &str) -> Result<PathBuf, String> {
let (owner, rest) = path
.split_once('/')
.ok_or_else(|| format!("package path '{}' is not in owner/repo form", path))?;
let (repo, sub) = if let Some((r, s)) = rest.split_once('/') {
(r, Some(s))
} else {
(rest, None)
};
let cache_dir = pkg_dir_for(owner, repo)
.ok_or_else(|| "could not determine home directory for package cache".to_string())?;
if !cache_dir.exists() {
return Err(format!(
"package '{}/{repo}' is not installed — run `ilo add {owner}/{repo}` first",
owner
));
}
let file_path = match sub {
Some(s) => cache_dir.join(s),
None => cache_dir.join("index.ilo"),
};
Ok(file_path)
}
pub fn is_semver_constraint(ref_str: &str) -> bool {
if ref_str.starts_with('^') || ref_str.starts_with('~') {
return true;
}
if ref_str.starts_with(">=")
|| ref_str.starts_with("<=")
|| ref_str.starts_with('>')
|| ref_str.starts_with('<')
|| ref_str.starts_with('=')
{
return true;
}
if ref_str == "*" {
return true;
}
if ref_str.contains("||") || ref_str.contains(' ') {
return true;
}
if ref_str.contains(".x") || ref_str.contains(".X") || ref_str.contains(".*") {
return true;
}
let parts: Vec<&str> = ref_str.splitn(3, '.').collect();
if parts.len() == 3 {
return parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()));
}
false
}
pub fn resolve_semver_ref(url: &str, constraint_str: &str) -> Result<String, String> {
let needs_eq_prefix = !constraint_str.starts_with('^')
&& !constraint_str.starts_with('~')
&& !constraint_str.starts_with('>')
&& !constraint_str.starts_with('<')
&& !constraint_str.starts_with('=')
&& !constraint_str.starts_with('*')
&& !constraint_str.contains("||")
&& !constraint_str.contains(' ')
&& !constraint_str.contains(".x")
&& !constraint_str.contains(".X")
&& !constraint_str.contains(".*");
let req_str = if needs_eq_prefix {
format!("={constraint_str}")
} else {
constraint_str.to_string()
};
let req = VersionReq::parse(&req_str)
.map_err(|e| format!("invalid semver constraint '{}': {}", constraint_str, e))?;
let output = Command::new("git")
.args(["ls-remote", "--tags", url])
.output()
.map_err(|e| format!("git ls-remote failed: {}", e))?;
if !output.status.success() {
return Err(format!("git ls-remote returned non-zero for {}", url));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut best: Option<(Version, String)> = None;
for line in stdout.lines() {
let Some((_sha, tag_ref)) = line.split_once('\t') else {
continue;
};
if tag_ref.ends_with("^{}") {
continue;
}
let tag_name = tag_ref.strip_prefix("refs/tags/").unwrap_or(tag_ref);
let version_str = tag_name.strip_prefix('v').unwrap_or(tag_name);
let Ok(version) = Version::parse(version_str) else {
continue;
};
if req.matches(&version) {
let replace = match &best {
None => true,
Some((prev, _)) => version > *prev,
};
if replace {
best = Some((version, tag_name.to_string()));
}
}
}
best.map(|(_, tag)| tag).ok_or_else(|| {
format!(
"no tag found matching semver constraint '{}'",
constraint_str
)
})
}
pub fn cmd_add(spec: &str) -> i32 {
let mut visited: HashSet<String> = HashSet::new();
let mut stack: Vec<String> = Vec::new();
add_recursive(spec, &mut visited, &mut stack)
}
fn add_recursive(spec: &str, visited: &mut HashSet<String>, stack: &mut Vec<String>) -> i32 {
let Some((owner, repo, git_ref)) = parse_package_spec(spec) else {
eprintln!(
"error: '{}' is not a valid package spec.\n\
Expected: owner/repo or owner/repo@ref",
spec
);
return 1;
};
let slug = format!("{owner}/{repo}");
if stack.contains(&slug) {
let cycle: Vec<&str> = stack
.iter()
.skip_while(|s| s.as_str() != slug.as_str())
.map(|s| s.as_str())
.collect();
eprintln!(
"error: dependency cycle detected: {} -> {}",
cycle.join(" -> "),
slug
);
return 1;
}
if visited.contains(&slug) {
return 0;
}
let token = load_github_token();
let url = github_clone_url(owner, repo, token.as_deref());
let resolved_owned: Option<String> = match git_ref {
Some(r) if is_semver_constraint(r) => match resolve_semver_ref(&url, r) {
Ok(tag) => {
println!("resolved semver '{}' → {}", r, tag);
Some(tag)
}
Err(e) => {
eprintln!("error: {}", e);
return 1;
}
},
_ => None,
};
let git_ref: &str = match &resolved_owned {
Some(tag) => tag.as_str(),
None => git_ref.unwrap_or("HEAD"),
};
let Some(dest) = pkg_dir_for(owner, repo) else {
eprintln!("error: could not determine home directory");
return 1;
};
if dest.exists() {
if let Err(e) = std::fs::remove_dir_all(&dest) {
eprintln!(
"error: could not remove existing cache at {}: {}",
dest.display(),
e
);
return 1;
}
}
if let Some(parent) = dest.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"error: could not create cache directory {}: {}",
parent.display(),
e
);
return 1;
}
}
let clone_status = Command::new("git")
.args([
"clone",
"--depth=1",
"--single-branch",
"--branch",
git_ref,
&url,
dest.to_str().unwrap_or(""),
])
.status();
let clone_ok = match clone_status {
Ok(s) => s.success(),
Err(_) => false,
};
if !clone_ok {
let fallback = Command::new("git")
.args(["clone", "--depth=1", &url, dest.to_str().unwrap_or("")])
.status();
match fallback {
Ok(s) if s.success() => {}
_ => {
eprintln!("error: git clone failed for {}/{}", owner, repo);
eprintln!(" Make sure the repo exists and you have network access.");
return 1;
}
}
if git_ref != "HEAD" {
let checkout = Command::new("git")
.args(["-C", dest.to_str().unwrap_or(""), "checkout", git_ref])
.status();
if !checkout.map(|s| s.success()).unwrap_or(false) {
eprintln!(
"error: could not checkout ref '{}' in {}/{}",
git_ref, owner, repo
);
return 1;
}
}
}
let sha = resolved_sha(&dest);
update_lockfile(
owner,
repo,
&sha,
&format!("https://github.com/{owner}/{repo}"),
);
println!("added {owner}/{repo} @ {sha}");
println!(" cache: {}", dest.display());
visited.insert(slug.clone());
stack.push(slug.clone());
let deps = collect_pkg_deps(&dest);
for dep_slug in deps {
let rc = add_recursive(&dep_slug, visited, stack);
if rc != 0 {
stack.pop();
return rc;
}
}
stack.pop();
0
}
fn collect_pkg_deps(pkg_dir: &Path) -> Vec<String> {
let read_dir = match std::fs::read_dir(pkg_dir) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut deps: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("ilo") {
continue;
}
let Ok(source) = std::fs::read_to_string(&path) else {
continue;
};
for dep in extract_use_pkg_slugs(&source) {
if seen.insert(dep.clone()) {
deps.push(dep);
}
}
}
deps
}
fn extract_use_pkg_slugs(source: &str) -> Vec<String> {
let mut slugs = Vec::new();
let chars = source.char_indices();
for (i, ch) in chars {
if ch != 'u' {
continue;
}
if i > 0 {
let prev = source[..i].chars().next_back().unwrap_or(' ');
if prev.is_alphanumeric() || prev == '_' {
continue;
}
}
let rest = &source[i..];
if !rest.starts_with("use") {
continue;
}
let after_use = &rest[3..];
let next_ch = after_use.chars().next().unwrap_or(' ');
if next_ch.is_alphanumeric() || next_ch == '_' {
continue;
}
let trimmed = after_use.trim_start_matches([' ', '\t']);
if !trimmed.starts_with('"') {
continue;
}
let inner = &trimmed[1..];
let end = inner.find('"').unwrap_or(inner.len());
let path = &inner[..end];
if !is_pkg_path(path) {
continue;
}
let slug = path.splitn(3, '/').take(2).collect::<Vec<_>>().join("/");
if slug.contains('/') {
slugs.push(slug);
}
}
slugs
}
pub fn cmd_update(package: Option<&str>) -> i32 {
let lock_entries = read_lockfile();
if lock_entries.is_empty() {
println!("ilo.lock is empty — nothing to update.");
return 0;
}
let mut exit = 0;
for entry in &lock_entries {
if let Some(pkg) = package {
if entry.slug != pkg {
continue;
}
}
let rc = cmd_add(&entry.slug);
if rc != 0 {
exit = 1;
}
}
exit
}
struct LockEntry {
slug: String,
}
fn read_lockfile() -> Vec<LockEntry> {
let path = std::path::Path::new("ilo.lock");
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.filter_map(|l| {
let cols: Vec<&str> = l.splitn(3, '\t').collect();
cols.first().map(|s| LockEntry {
slug: s.to_string(),
})
})
.collect()
}
fn update_lockfile(owner: &str, repo: &str, sha: &str, url: &str) {
let slug = format!("{owner}/{repo}");
let path = Path::new("ilo.lock");
let existing = if path.exists() {
std::fs::read_to_string(path).unwrap_or_default()
} else {
String::new()
};
let new_line = format!("{slug}\t{sha}\t{url}");
let mut found = false;
let mut lines: Vec<String> = existing
.lines()
.map(|l| {
if !l.starts_with('#') {
let cols: Vec<&str> = l.splitn(2, '\t').collect();
if cols.first().copied() == Some(slug.as_str()) {
found = true;
return new_line.clone();
}
}
l.to_string()
})
.collect();
if !found {
if lines.is_empty() {
lines.push("# ilo.lock — generated by `ilo add`; commit to source control".to_string());
}
lines.push(new_line);
}
let content = lines.join("\n") + "\n";
if let Err(e) = std::fs::write(path, content) {
eprintln!("warning: could not write ilo.lock: {}", e);
}
}
fn resolved_sha(repo_dir: &Path) -> String {
Command::new("git")
.args(["-C", repo_dir.to_str().unwrap_or("."), "rev-parse", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_spec() {
let r = parse_package_spec("myorg/helpers");
assert_eq!(r, Some(("myorg", "helpers", None)));
}
#[test]
fn parse_spec_with_ref() {
let r = parse_package_spec("myorg/helpers@v1.2");
assert_eq!(r, Some(("myorg", "helpers", Some("v1.2"))));
}
#[test]
fn parse_github_url() {
let r = parse_package_spec("https://github.com/myorg/helpers");
assert_eq!(r, Some(("myorg", "helpers", None)));
}
#[test]
fn parse_bad_spec_returns_none() {
assert!(parse_package_spec("notaslug").is_none());
assert!(parse_package_spec("").is_none());
}
#[test]
fn is_pkg_path_true_for_owner_repo() {
assert!(is_pkg_path("myorg/helpers"));
assert!(is_pkg_path("myorg/helpers/utils.ilo"));
}
#[test]
fn is_pkg_path_false_for_local() {
assert!(!is_pkg_path("./lib.ilo"));
assert!(!is_pkg_path("../shared.ilo"));
assert!(!is_pkg_path("/abs/path.ilo"));
assert!(is_pkg_path("relative/path.ilo"));
}
#[test]
fn semver_constraint_detection() {
assert!(is_semver_constraint("^1.2"));
assert!(is_semver_constraint("^1"));
assert!(is_semver_constraint("~1.2.3"));
assert!(is_semver_constraint("1.2.3"));
assert!(is_semver_constraint(">=1.2.0"));
assert!(is_semver_constraint(">=1.2"));
assert!(is_semver_constraint(">1.0.0"));
assert!(is_semver_constraint("<2.0.0"));
assert!(is_semver_constraint("<=1.9.9"));
assert!(is_semver_constraint("=1.0.0"));
assert!(is_semver_constraint("*"));
assert!(is_semver_constraint("1.x"));
assert!(is_semver_constraint("1.2.x"));
assert!(is_semver_constraint("1.*"));
assert!(is_semver_constraint(">=1.2 <2"));
assert!(is_semver_constraint("1.0.0 || 2.0.0"));
assert!(is_semver_constraint(">=1.0.0 <2.0.0 || >=3.0.0"));
assert!(!is_semver_constraint("v1.2.3"));
assert!(!is_semver_constraint("main"));
assert!(!is_semver_constraint("HEAD"));
assert!(!is_semver_constraint("abc123"));
assert!(!is_semver_constraint("1.2"));
}
#[test]
fn is_pkg_path_false_for_relative_with_extension() {
assert!(is_pkg_path("relative/no-ext-dir"));
}
#[test]
fn extract_use_finds_pkg_dep() {
let source = r#"use "myorg/helpers""#;
assert_eq!(extract_use_pkg_slugs(source), vec!["myorg/helpers"]);
}
#[test]
fn extract_use_finds_sub_path_and_truncates_to_slug() {
let source = r#"use "myorg/helpers/utils.ilo""#;
assert_eq!(extract_use_pkg_slugs(source), vec!["myorg/helpers"]);
}
#[test]
fn extract_use_ignores_local_paths() {
let source = r#"use "./lib.ilo"
use "../shared.ilo"
use "/abs/path.ilo""#;
assert!(extract_use_pkg_slugs(source).is_empty());
}
#[test]
fn extract_use_returns_all_occurrences() {
let source = r#"use "myorg/helpers"
use "myorg/helpers/sub.ilo""#;
assert_eq!(
extract_use_pkg_slugs(source),
vec!["myorg/helpers", "myorg/helpers"]
);
}
#[test]
fn extract_use_finds_multiple_deps() {
let source = r#"use "org1/pkg1"
use "org2/pkg2""#;
let mut got = extract_use_pkg_slugs(source);
got.sort();
assert_eq!(got, vec!["org1/pkg1", "org2/pkg2"]);
}
#[test]
fn extract_use_ignores_non_use_keyword() {
let source = r#"fuse "org/pkg1"
reuse "org/pkg2""#;
assert!(extract_use_pkg_slugs(source).is_empty());
}
#[test]
fn extract_use_multiline_source() {
let source = "add a b; use \"myorg/math\"; mul x y";
assert_eq!(extract_use_pkg_slugs(source), vec!["myorg/math"]);
}
#[test]
fn add_recursive_detects_self_cycle() {
let mut visited = std::collections::HashSet::new();
let mut stack = vec!["selfpkg/lib".to_string()];
let rc = add_recursive("selfpkg/lib", &mut visited, &mut stack);
assert_eq!(rc, 1);
}
#[test]
fn add_recursive_skips_already_visited() {
let mut visited = std::collections::HashSet::new();
visited.insert("myorg/helpers".to_string());
let mut stack = Vec::new();
let rc = add_recursive("myorg/helpers", &mut visited, &mut stack);
assert_eq!(rc, 0);
}
#[test]
fn github_clone_url_no_token() {
let url = github_clone_url("myorg", "myrepo", None);
assert_eq!(url, "https://github.com/myorg/myrepo.git");
}
#[test]
fn github_clone_url_with_token() {
let url = github_clone_url("myorg", "myrepo", Some("ghp_secret"));
assert_eq!(url, "https://ghp_secret@github.com/myorg/myrepo.git");
let lock_url = "https://github.com/myorg/myrepo".to_string();
assert!(!lock_url.contains("ghp_secret"));
}
#[test]
fn load_github_token_from_env() {
unsafe { std::env::set_var("GITHUB_TOKEN", "tok_from_env") };
let tok = load_github_token();
unsafe { std::env::remove_var("GITHUB_TOKEN") };
assert_eq!(tok.as_deref(), Some("tok_from_env"));
}
#[test]
fn load_github_token_empty_env_ignored() {
unsafe { std::env::set_var("GITHUB_TOKEN", "") };
let tok = load_github_token();
unsafe { std::env::remove_var("GITHUB_TOKEN") };
assert_ne!(tok.as_deref(), Some(""));
}
}