use super::error::{ProfileError, ProfileResult};
use super::fsops::{
ProfileBackup, backup_profile, calculate_integrity, collect_all_files, restore_profile,
};
use super::installer::{self, ProfileInstaller};
use super::lockfile::{ProfileLockEntry, ProfileLockfile};
use super::manifest::ProfileManifest;
use super::project::ProfilesConfig;
use super::provider_registry::ProviderSource;
use std::path::Path;
fn fail_with_rollback(
primary: ProfileError,
backup: Option<&ProfileBackup>,
lockfile_rollback: Option<(&mut ProfileLockfile, &Path, &str)>,
) -> ProfileError {
let mut failures = Vec::new();
if let Some((lockfile, path, profile_name)) = lockfile_rollback {
lockfile.remove_profile(profile_name);
if let Err(e) = lockfile.save(path) {
failures.push(format!("lockfile rollback save failed: {e}"));
}
}
if let Some(b) = backup {
if let Err(e) = restore_profile(b) {
failures.push(format!("backup restore failed: {e}"));
}
}
if failures.is_empty() {
return primary;
}
for failure in &failures {
eprintln!("Warning: {failure}");
}
ProfileError::RollbackFailed {
primary: Box::new(primary),
failures,
}
}
pub fn install_profile(
profile_name: &str,
profiles_dir: &Path,
workspace: &Path,
force: bool,
commit: Option<String>,
provider_id: Option<&str>,
source: Option<ProviderSource>,
) -> ProfileResult<()> {
let lockfile_path = workspace.join(".codanna/profiles.lock.json");
let mut lockfile = ProfileLockfile::load(&lockfile_path)?;
let mut backup: Option<ProfileBackup> = None;
let profile_dir = profiles_dir.join(profile_name);
let manifest_path = profile_dir.join("profile.json");
if !manifest_path.exists() {
return Err(ProfileError::InvalidManifest {
reason: format!(
"Profile '{profile_name}' not found at {}",
manifest_path.display()
),
});
}
let manifest = ProfileManifest::from_file(&manifest_path)?;
if let Some(existing) = lockfile.get_profile(profile_name) {
if !force {
return Err(ProfileError::AlreadyInstalled {
name: profile_name.to_string(),
version: existing.version.clone(),
});
}
backup = Some(backup_profile(workspace, existing)?);
}
let files_to_install = if manifest.files.is_empty() {
collect_all_files(&profile_dir)?
} else {
manifest.files.clone()
};
installer::check_all_conflicts(workspace, &files_to_install, profile_name, &lockfile, force)?;
let installer = ProfileInstaller::new();
let provider_name = manifest.provider_name();
let (installed_files, sidecars) = match installer.install_files(
&profile_dir,
workspace,
&files_to_install,
profile_name,
provider_name,
&lockfile,
force,
) {
Ok(result) => result,
Err(e) => {
return Err(fail_with_rollback(e, backup.as_ref(), None));
}
};
if !sidecars.is_empty() {
eprintln!("\nWarning: File conflicts resolved with sidecar files:");
for (intended, actual) in &sidecars {
eprintln!(" {intended} exists - installed as {actual}");
eprintln!(" Original file preserved");
}
eprintln!("\nReview and manually merge sidecar files with originals.");
}
let absolute_files: Vec<String> = installed_files
.iter()
.map(|rel| workspace.join(rel).to_string_lossy().to_string())
.collect();
let integrity = match calculate_integrity(&absolute_files) {
Ok(hash) => hash,
Err(e) => {
return Err(fail_with_rollback(e, backup.as_ref(), None));
}
};
let entry = ProfileLockEntry {
name: profile_name.to_string(),
version: manifest.version.clone(),
installed_at: current_timestamp(),
files: installed_files,
integrity,
commit,
provider_id: provider_id.map(String::from),
source,
};
lockfile.add_profile(entry);
if let Err(e) = lockfile.save(&lockfile_path) {
lockfile.remove_profile(profile_name);
return Err(fail_with_rollback(e, backup.as_ref(), None));
}
let profiles_config_path = workspace.join(".codanna/profiles.json");
let mut profiles_config = ProfilesConfig::load(&profiles_config_path)?;
let profile_ref = if let Some(provider) = provider_id {
format!("{profile_name}@{provider}")
} else {
profile_name.to_string()
};
profiles_config.add_profile(&profile_ref);
if let Err(e) = profiles_config.save(&profiles_config_path) {
return Err(fail_with_rollback(
e,
backup.as_ref(),
Some((&mut lockfile, &lockfile_path, profile_name)),
));
}
Ok(())
}
fn current_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards");
let secs = duration.as_secs();
let days = secs / 86400;
let years = 1970 + days / 365;
let day_of_year = days % 365;
let month = (day_of_year / 30) + 1;
let day = (day_of_year % 30) + 1;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
format!("{years:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn profiles_config_save_failure_rolls_back_lockfile() {
use std::os::unix::fs::PermissionsExt;
let profiles_dir = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let pdir = profiles_dir.path().join("demo");
std::fs::create_dir_all(&pdir).unwrap();
std::fs::write(
pdir.join("profile.json"),
r#"{"name":"demo","version":"0.1.0","files":["hello.md"]}"#,
)
.unwrap();
std::fs::write(pdir.join("hello.md"), "hi").unwrap();
let codanna_dir = workspace.path().join(".codanna");
std::fs::create_dir_all(&codanna_dir).unwrap();
let profiles_json = codanna_dir.join("profiles.json");
std::fs::write(&profiles_json, r#"{"version":1,"profiles":[]}"#).unwrap();
let mut perms = std::fs::metadata(&profiles_json).unwrap().permissions();
perms.set_mode(0o444);
std::fs::set_permissions(&profiles_json, perms).unwrap();
let result = install_profile(
"demo",
profiles_dir.path(),
workspace.path(),
false,
None,
None,
None,
);
assert!(result.is_err());
let lockfile =
ProfileLockfile::load(&workspace.path().join(".codanna/profiles.lock.json")).unwrap();
assert!(lockfile.get_profile("demo").is_none());
}
}