use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::ScaffoldError;
use super::ids::{now_iso8601, random_uuid_v4};
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum InitAction {
Project,
Global,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunxInitOptions {
pub action: InitAction,
pub project_dir: PathBuf,
pub global_home_dir: PathBuf,
pub official_cache_dir: PathBuf,
pub prefetch_official: bool,
pub generated: InitGeneratedValues,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InitGeneratedValues {
pub project_id: String,
pub installation_id: String,
pub created_at: String,
}
impl InitGeneratedValues {
#[must_use]
pub fn generate() -> Self {
Self {
project_id: format!("proj_{}", random_uuid_v4()),
installation_id: format!("inst_{}", random_uuid_v4()),
created_at: now_iso8601(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunxProjectState {
pub version: u8,
pub project_id: String,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunxInstallState {
pub version: u8,
pub installation_id: String,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct RunxInitResult {
pub action: InitAction,
pub created: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub global_home_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub installation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub official_cache_dir: Option<PathBuf>,
}
pub fn runx_init(options: &RunxInitOptions) -> Result<RunxInitResult, ScaffoldError> {
match options.action {
InitAction::Project => {
let ensured = ensure_runx_project_state(
&options.project_dir,
&options.generated.project_id,
&options.generated.created_at,
)?;
let skills_dir = options.project_dir.join("skills");
let tools_dir = options.project_dir.join("tools");
fs::create_dir_all(&skills_dir).map_err(|source| {
ScaffoldError::io("creating skills directory", skills_dir, source)
})?;
fs::create_dir_all(&tools_dir).map_err(|source| {
ScaffoldError::io("creating tools directory", tools_dir, source)
})?;
Ok(RunxInitResult {
action: InitAction::Project,
created: ensured.created,
project_dir: Some(options.project_dir.clone()),
project_id: Some(ensured.state.project_id),
global_home_dir: None,
installation_id: None,
official_cache_dir: None,
})
}
InitAction::Global => {
let ensured = ensure_runx_install_state(
&options.global_home_dir,
&options.generated.installation_id,
&options.generated.created_at,
)?;
if options.prefetch_official {
fs::create_dir_all(&options.official_cache_dir).map_err(|source| {
ScaffoldError::io(
"creating official skills cache",
&options.official_cache_dir,
source,
)
})?;
}
Ok(RunxInitResult {
action: InitAction::Global,
created: ensured.created,
project_dir: None,
project_id: None,
global_home_dir: Some(options.global_home_dir.clone()),
installation_id: Some(ensured.state.installation_id),
official_cache_dir: options
.prefetch_official
.then(|| options.official_cache_dir.clone()),
})
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnsuredProjectState {
pub state: RunxProjectState,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnsuredInstallState {
pub state: RunxInstallState,
pub created: bool,
}
pub fn ensure_runx_project_state(
project_dir: &Path,
project_id: &str,
created_at: &str,
) -> Result<EnsuredProjectState, ScaffoldError> {
if let Some(existing) = read_runx_project_state(project_dir)? {
return Ok(EnsuredProjectState {
state: existing,
created: false,
});
}
let state = RunxProjectState {
version: 1,
project_id: project_id.to_owned(),
created_at: created_at.to_owned(),
};
fs::create_dir_all(project_dir)
.map_err(|source| ScaffoldError::io("creating project directory", project_dir, source))?;
write_state_json(&project_dir.join("project.json"), &state)?;
Ok(EnsuredProjectState {
state,
created: true,
})
}
pub fn ensure_runx_install_state(
global_home_dir: &Path,
installation_id: &str,
created_at: &str,
) -> Result<EnsuredInstallState, ScaffoldError> {
if let Some(existing) = read_runx_install_state(global_home_dir)? {
return Ok(EnsuredInstallState {
state: existing,
created: false,
});
}
let state = RunxInstallState {
version: 1,
installation_id: installation_id.to_owned(),
created_at: created_at.to_owned(),
};
fs::create_dir_all(global_home_dir).map_err(|source| {
ScaffoldError::io("creating global home directory", global_home_dir, source)
})?;
write_state_json(&global_home_dir.join("install.json"), &state)?;
Ok(EnsuredInstallState {
state,
created: true,
})
}
fn read_runx_project_state(project_dir: &Path) -> Result<Option<RunxProjectState>, ScaffoldError> {
let path = project_dir.join("project.json");
match fs::read_to_string(&path) {
Ok(contents) => {
let state: RunxProjectState = serde_json::from_str(&contents)
.map_err(|source| ScaffoldError::json("reading project state", &path, source))?;
validate_project_state(path, state).map(Some)
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(ScaffoldError::io("reading project state", path, source)),
}
}
fn read_runx_install_state(
global_home_dir: &Path,
) -> Result<Option<RunxInstallState>, ScaffoldError> {
let path = global_home_dir.join("install.json");
match fs::read_to_string(&path) {
Ok(contents) => {
let state: RunxInstallState = serde_json::from_str(&contents)
.map_err(|source| ScaffoldError::json("reading install state", &path, source))?;
validate_install_state(path, state).map(Some)
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(ScaffoldError::io("reading install state", path, source)),
}
}
fn validate_project_state(
path: PathBuf,
state: RunxProjectState,
) -> Result<RunxProjectState, ScaffoldError> {
if state.version != 1 || state.project_id.is_empty() || state.created_at.is_empty() {
return Err(ScaffoldError::InvalidState {
path,
message: "expected version 1, project_id, and created_at".to_owned(),
});
}
Ok(state)
}
fn validate_install_state(
path: PathBuf,
state: RunxInstallState,
) -> Result<RunxInstallState, ScaffoldError> {
if state.version != 1 || state.installation_id.is_empty() || state.created_at.is_empty() {
return Err(ScaffoldError::InvalidState {
path,
message: "expected version 1, installation_id, and created_at".to_owned(),
});
}
Ok(state)
}
fn write_state_json<T: Serialize>(path: &Path, state: &T) -> Result<(), ScaffoldError> {
let contents = serde_json::to_string_pretty(state)
.map_err(|source| ScaffoldError::json("serializing state", path, source))?;
fs::write(path, format!("{contents}\n"))
.map_err(|source| ScaffoldError::io("writing state", path, source))?;
set_private_file_mode(path)
}
#[cfg(unix)]
fn set_private_file_mode(path: &Path) -> Result<(), ScaffoldError> {
use std::os::unix::fs::PermissionsExt;
let permissions = fs::Permissions::from_mode(0o600);
fs::set_permissions(path, permissions)
.map_err(|source| ScaffoldError::io("setting state permissions", path, source))
}
#[cfg(not(unix))]
fn set_private_file_mode(_path: &Path) -> Result<(), ScaffoldError> {
Ok(())
}