use std::path::PathBuf;
use serde::Serialize;
use crate::component::{self, Component};
use crate::error::{Error, Result};
use crate::extension::{self, ExtensionCapability, ExtensionExecutionContext};
#[derive(Debug, Clone, Serialize)]
pub struct ExecutionContext {
#[serde(skip)]
pub component: Component,
pub component_id: String,
pub source_path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_root: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension_path: Option<PathBuf>,
pub settings: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default)]
pub struct ResolveOptions {
pub component_id: Option<String>,
pub path_override: Option<String>,
pub capability: Option<ExtensionCapability>,
pub settings_overrides: Vec<(String, String)>,
}
impl ResolveOptions {
pub fn with_capability(
component_id: &str,
path_override: Option<String>,
capability: ExtensionCapability,
settings: Vec<(String, String)>,
) -> Self {
Self {
component_id: Some(component_id.to_string()),
path_override,
capability: Some(capability),
settings_overrides: settings,
}
}
pub fn source_only(component_id: &str, path_override: Option<String>) -> Self {
Self {
component_id: Some(component_id.to_string()),
path_override,
capability: None,
settings_overrides: Vec::new(),
}
}
}
pub fn resolve(options: &ResolveOptions) -> Result<ExecutionContext> {
let component = component::resolve_effective(
options.component_id.as_deref(),
options.path_override.as_deref(),
None,
)?;
let source_path = if let Some(ref path) = options.path_override {
PathBuf::from(path)
} else {
let expanded = shellexpand::tilde(&component.local_path);
PathBuf::from(expanded.as_ref())
};
let git_root = detect_git_root(&source_path);
let (extension_id, extension_path, settings) = if let Some(capability) = options.capability {
let ext_context = extension::resolve_execution_context(&component, capability)?;
let mut settings = ext_context.settings.clone();
for (key, value) in &options.settings_overrides {
settings.retain(|(k, _)| k != key);
settings.push((key.clone(), value.clone()));
}
(
Some(ext_context.extension_id.clone()),
Some(ext_context.extension_path.clone()),
settings,
)
} else {
(None, None, options.settings_overrides.clone())
};
Ok(ExecutionContext {
component_id: component.id.clone(),
component,
source_path,
git_root,
extension_id,
extension_path,
settings,
})
}
impl ExecutionContext {
pub fn to_extension_context(
&self,
capability: ExtensionCapability,
) -> Result<ExtensionExecutionContext> {
let extension_id = self.extension_id.as_ref().ok_or_else(|| {
Error::validation_invalid_argument(
"capability",
"No extension was resolved for this execution context",
None,
Some(vec![
"Use ResolveOptions::with_capability() to resolve an extension".to_string(),
]),
)
})?;
let extension_path = self.extension_path.as_ref().ok_or_else(|| {
Error::validation_invalid_argument(
"extension_path",
"Extension path not resolved",
None,
None,
)
})?;
let manifest = extension::load_extension(extension_id)?;
let script_path = match capability {
ExtensionCapability::Lint => manifest.lint_script(),
ExtensionCapability::Test => manifest.test_script(),
ExtensionCapability::Build => manifest.build_script(),
}
.map(|s| s.to_string())
.or_else(|| {
if capability == ExtensionCapability::Build {
Some(String::new())
} else {
None
}
})
.ok_or_else(|| {
Error::validation_invalid_argument(
"extension",
format!(
"Extension '{}' does not have {} infrastructure configured",
extension_id,
match capability {
ExtensionCapability::Lint => "lint",
ExtensionCapability::Test => "test",
ExtensionCapability::Build => "build",
}
),
None,
None,
)
})?;
Ok(ExtensionExecutionContext {
component: self.component.clone(),
capability,
extension_id: extension_id.clone(),
extension_path: extension_path.clone(),
script_path,
settings: self.settings.clone(),
})
}
pub fn working_dir(&self) -> &str {
self.source_path.to_str().unwrap_or(".")
}
pub fn log_debug(&self) {
crate::log_status!("context", "component_id: {}", self.component_id);
crate::log_status!("context", "source_path: {}", self.source_path.display());
crate::log_status!(
"context",
"git_root: {}",
self.git_root
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(none)".to_string())
);
crate::log_status!(
"context",
"extension_id: {}",
self.extension_id.as_deref().unwrap_or("(none)")
);
crate::log_status!(
"context",
"extension_path: {}",
self.extension_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(none)".to_string())
);
if !self.settings.is_empty() {
crate::log_status!(
"context",
"settings: {}",
self.settings
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ")
);
}
}
}
fn detect_git_root(dir: &PathBuf) -> Option<PathBuf> {
let effective_dir = if dir.is_file() {
dir.parent()?
} else if dir.exists() {
dir.as_path()
} else {
dir.parent()?
};
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(effective_dir)
.output()
.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn detect_git_root_finds_repo() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.expect("git init");
let result = detect_git_root(&root.to_path_buf());
assert!(result.is_some());
assert_eq!(result.unwrap(), root.canonicalize().unwrap());
}
#[test]
fn detect_git_root_returns_none_outside_repo() {
let dir = TempDir::new().expect("temp dir");
let non_git = dir.path().join("not-a-repo");
fs::create_dir_all(&non_git).expect("create dir");
let result = detect_git_root(&non_git.to_path_buf());
assert!(result.is_none() || result.is_some());
}
#[test]
fn resolve_source_only_with_path() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root).expect("create dir");
let options =
ResolveOptions::source_only("test-comp", Some(root.to_string_lossy().to_string()));
let ctx = resolve(&options).expect("resolve should succeed");
assert_eq!(ctx.component_id, "test-comp");
assert_eq!(ctx.source_path, root);
assert!(ctx.extension_id.is_none());
}
#[test]
fn resolve_source_only_with_path_in_git_repo() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.expect("git init");
let sub = root.join("src");
fs::create_dir_all(&sub).expect("create src dir");
let options =
ResolveOptions::source_only("test-comp", Some(sub.to_string_lossy().to_string()));
let ctx = resolve(&options).expect("resolve should succeed");
assert!(ctx.git_root.is_some());
assert_eq!(ctx.git_root.unwrap(), root.canonicalize().unwrap());
}
#[test]
fn settings_overrides_replace_existing() {
let options = ResolveOptions {
component_id: Some("test".to_string()),
path_override: Some("/tmp".to_string()),
capability: None,
settings_overrides: vec![
("mode".to_string(), "strict".to_string()),
("lang".to_string(), "rust".to_string()),
],
};
let ctx = resolve(&options).expect("resolve should succeed");
assert_eq!(ctx.settings.len(), 2);
assert!(ctx
.settings
.iter()
.any(|(k, v)| k == "mode" && v == "strict"));
}
}