use std::{
cell::RefCell,
collections::BTreeMap,
path::{Path, PathBuf},
};
use crate::{Scope, ScopeError};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ScopeResolveError {
#[error("override scope failed validation: {0}")]
InvalidOverride(#[source] ScopeError),
#[error("could not resolve home directory")]
NoHome,
#[error("scope store io: {0}")]
Io(#[from] std::io::Error),
#[error("scope store parse: {0}")]
Parse(#[from] serde_json::Error),
#[error("derived scope failed validation: {0}")]
DerivationInvalid(#[source] ScopeError),
#[error("{0}")]
NoStoreUrl(String),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ScopeRecord {
pub name: String,
pub created_at: String,
pub source: String,
}
pub trait ScopeStore {
fn read(&self) -> Result<BTreeMap<String, ScopeRecord>, ScopeResolveError>;
fn write(&self, scopes: &BTreeMap<String, ScopeRecord>) -> Result<(), ScopeResolveError>;
}
pub struct InMemoryScopeStore {
inner: RefCell<BTreeMap<String, ScopeRecord>>,
}
impl InMemoryScopeStore {
pub fn new() -> Self {
Self { inner: RefCell::new(BTreeMap::new()) }
}
}
impl Default for InMemoryScopeStore {
fn default() -> Self {
Self::new()
}
}
impl ScopeStore for InMemoryScopeStore {
fn read(&self) -> Result<BTreeMap<String, ScopeRecord>, ScopeResolveError> {
Ok(self.inner.borrow().clone())
}
fn write(&self, scopes: &BTreeMap<String, ScopeRecord>) -> Result<(), ScopeResolveError> {
*self.inner.borrow_mut() = scopes.clone();
Ok(())
}
}
pub fn resolve_with<S: ScopeStore>(
cwd: &Path,
store: &S,
override_: Option<&str>,
) -> Result<Scope, ScopeResolveError> {
if let Some(o) = override_ {
return Scope::new(o).map_err(ScopeResolveError::InvalidOverride);
}
let (derivation_key, candidate_name, source) = derive_from_env(cwd);
let mut scopes = store.read()?;
let scope_str = if let Some(entry) = scopes.get(&derivation_key) {
entry.name.clone()
} else {
let record =
ScopeRecord { name: candidate_name.clone(), created_at: rfc3339_now(), source };
scopes.insert(derivation_key.clone(), record);
store.write(&scopes)?;
candidate_name
};
Scope::new(&scope_str).map_err(ScopeResolveError::DerivationInvalid)
}
pub fn scopes_file_path() -> Result<PathBuf, ScopeResolveError> {
if let Ok(path) = std::env::var("LUNARIS_SCOPES_FILE") {
return Ok(PathBuf::from(path));
}
let home = dirs::home_dir().ok_or(ScopeResolveError::NoHome)?;
Ok(home.join(".lunaris").join("scopes.json"))
}
pub fn blake3_hex64(input: &str) -> String {
let hash = blake3::hash(input.as_bytes());
hash.to_hex().to_string()
}
fn derive_from_env(cwd: &Path) -> (String, String, String) {
if let Some((url, branch)) = try_git_remote_and_branch(cwd) {
let raw = format!("{}@{}", url, branch);
let full_key = blake3_hex64(&raw);
let short = &full_key[..16];
let name = format!("git_{short}");
(full_key, name, "git".to_string())
} else {
let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
let raw = canonical.to_string_lossy();
let full_key = blake3_hex64(raw.as_ref());
let short = &full_key[..16];
let name = format!("cwd_{short}");
(full_key.clone(), name, "cwd".to_string())
}
}
fn try_git_remote_and_branch(cwd: &Path) -> Option<(String, String)> {
let url = std::process::Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.current_dir(cwd)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())?;
let url = url.trim().to_string();
if url.is_empty() {
return None;
}
let branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())?;
let branch = branch.trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return None;
}
Some((url, branch))
}
fn rfc3339_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let s = secs % 60;
let m = (secs / 60) % 60;
let h = (secs / 3600) % 24;
let total_days = secs / 86400;
let (y, mo, d) = days_to_ymd(total_days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
let y400 = days / 146097;
days %= 146097;
let y100 = (days / 36524).min(3);
days -= y100 * 36524;
let y4 = days / 1461;
days %= 1461;
let y1 = (days / 365).min(3);
days -= y1 * 365;
let year = y400 * 400 + y100 * 100 + y4 * 4 + y1 + 1970;
let leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
let month_days: [u64; 12] =
[31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month = 1u64;
for &md in &month_days {
if days < md {
break;
}
days -= md;
month += 1;
}
(year, month, days + 1)
}