Files
yoi/crates/manifest/src/profile.rs
T

1779 lines
58 KiB
Rust

//! Profile discovery and resolution.
//!
//! Profiles are reusable, human-authored recipes. They are intentionally not
//! complete runtime manifests: runtime-bound and authority-bearing fields such
//! as `worker.name` and concrete `scope.allow` rules are supplied by the resolver
//! from launch context.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
};
use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig, paths,
};
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
struct BuiltinProfile {
name: &'static str,
label: &'static str,
description: &'static str,
}
const BUILTIN_PROFILES: &[BuiltinProfile] = &[
BuiltinProfile {
name: "companion",
label: "builtin:companion",
description: "Bundled Companion role profile",
},
BuiltinProfile {
name: "intake",
label: "builtin:intake",
description: "Bundled Intake role profile",
},
BuiltinProfile {
name: "orchestrator",
label: "builtin:orchestrator",
description: "Bundled Orchestrator role profile",
},
BuiltinProfile {
name: "coder",
label: "builtin:coder",
description: "Bundled Coder role profile",
},
BuiltinProfile {
name: "reviewer",
label: "builtin:reviewer",
description: "Bundled Reviewer role profile",
},
BuiltinProfile {
name: "memory-consolidation",
label: "builtin:memory-consolidation",
description: "Bundled Memory staging consolidation profile",
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileRegistrySource {
Builtin,
User,
Project,
}
impl ProfileRegistrySource {
pub fn as_str(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::User => "user",
Self::Project => "project",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw {
"builtin" => Some(Self::Builtin),
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
}
impl std::fmt::Display for ProfileRegistrySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSelector {
Path {
path: PathBuf,
},
Named {
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<ProfileRegistrySource>,
name: String,
},
Default,
}
impl ProfileSelector {
pub fn path(path: impl Into<PathBuf>) -> Self {
Self::Path { path: path.into() }
}
pub fn named(name: impl Into<String>) -> Self {
Self::Named {
source: None,
name: name.into(),
}
}
pub fn source_named(source: ProfileRegistrySource, name: impl Into<String>) -> Self {
Self::Named {
source: Some(source),
name: name.into(),
}
}
pub fn parse_cli(raw: &str) -> Self {
if raw == "default" {
return Self::Default;
}
if let Some(path) = raw.strip_prefix("path:") {
return Self::path(path);
}
if let Some((prefix, name)) = raw.split_once(':')
&& let Some(source) = ProfileRegistrySource::parse(prefix)
{
return Self::source_named(source, name);
}
if raw.contains('/')
|| raw.starts_with('.')
|| raw.ends_with(".json")
|| raw.ends_with(".toml")
{
Self::path(raw)
} else {
Self::named(raw)
}
}
pub fn display_label(&self) -> String {
match self {
Self::Path { path } => path.display().to_string(),
Self::Named { source, name } => match source {
Some(source) => format!("{source}:{name}"),
None => name.clone(),
},
Self::Default => "default".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSource {
Path {
path: PathBuf,
},
Registry {
source: ProfileRegistrySource,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<String>,
},
Archive {
archive_id: String,
source: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileRegistryEntry {
pub source: ProfileRegistrySource,
pub name: String,
pub path: Option<PathBuf>,
pub provenance: String,
pub description: Option<String>,
pub is_default: bool,
artifact: ProfileRegistryArtifact,
}
impl ProfileRegistryEntry {
pub fn qualified_name(&self) -> String {
format!("{}:{}", self.source, self.name)
}
fn path(
source: ProfileRegistrySource,
name: String,
path: PathBuf,
description: Option<String>,
) -> Self {
let provenance = path.display().to_string();
Self {
source,
name,
path: Some(path.clone()),
provenance,
description,
is_default: false,
artifact: ProfileRegistryArtifact::Path(path),
}
}
fn embedded(
source: ProfileRegistrySource,
name: &'static str,
label: &'static str,
description: Option<String>,
) -> Self {
Self {
source,
name: name.to_string(),
path: None,
provenance: label.to_string(),
description,
is_default: false,
artifact: ProfileRegistryArtifact::Builtin { label },
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ProfileRegistryArtifact {
Path(PathBuf),
Builtin { label: &'static str },
}
#[derive(Debug, Clone, Default)]
pub struct ProfileRegistry {
entries: Vec<ProfileRegistryEntry>,
default: Option<ProfileDefault>,
}
impl ProfileRegistry {
pub fn entries(&self) -> &[ProfileRegistryEntry] {
&self.entries
}
pub fn default_entry(&self) -> Result<&ProfileRegistryEntry, ProfileError> {
let default = self
.default
.as_ref()
.ok_or(ProfileError::NoDefaultProfile)?;
self.select_named(default.source, &default.name)
}
pub fn select(
&self,
selector: &ProfileSelector,
) -> Result<&ProfileRegistryEntry, ProfileError> {
match selector {
ProfileSelector::Path { .. } => Err(ProfileError::InvalidProfile(
"path selectors are not registry entries".into(),
)),
ProfileSelector::Default => self.default_entry(),
ProfileSelector::Named { source, name } => self.select_named(*source, name),
}
}
fn select_named(
&self,
source: Option<ProfileRegistrySource>,
name: &str,
) -> Result<&ProfileRegistryEntry, ProfileError> {
let matches: Vec<_> = self
.entries
.iter()
.filter(|entry| entry.name == name && source.is_none_or(|s| s == entry.source))
.collect();
match matches.as_slice() {
[entry] => Ok(*entry),
[] => Err(ProfileError::ProfileNotFound {
selector: source.map_or_else(|| name.to_string(), |s| format!("{s}:{name}")),
}),
_ => Err(ProfileError::AmbiguousProfileName {
name: name.to_string(),
matches: matches.iter().map(|entry| entry.qualified_name()).collect(),
}),
}
}
fn push_entry(&mut self, entry: ProfileRegistryEntry) {
self.entries.push(entry);
}
fn set_default(&mut self, default: ProfileDefault) {
self.default = Some(default);
}
fn mark_default_flags(&mut self) {
let Some(default) = self.default.clone() else {
return;
};
let Ok(default_entry) = self.select_named(default.source, &default.name) else {
return;
};
let source = default_entry.source;
let name = default_entry.name.clone();
for entry in &mut self.entries {
entry.is_default = entry.source == source && entry.name == name;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileDefault {
source: Option<ProfileRegistrySource>,
name: String,
}
#[derive(Debug, Clone)]
pub struct ProfileDiscovery {
user_config: Option<PathBuf>,
project_config: Option<PathBuf>,
}
impl ProfileDiscovery {
pub fn for_cwd(_cwd: &Path) -> Self {
Self {
user_config: paths::user_profiles_path(),
project_config: None,
}
}
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
Self {
user_config,
project_config,
}
}
pub fn discover(&self) -> Result<ProfileRegistry, ProfileError> {
let mut registry = ProfileRegistry::default();
add_builtin_profiles(&mut registry);
if let Some(path) = &self.user_config {
load_profile_registry_file(&mut registry, ProfileRegistrySource::User, path)?;
}
if let Some(path) = &self.project_config {
load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?;
}
registry.mark_default_flags();
Ok(registry)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileManifestSnapshot {
pub source: ProfileSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileMetadata>,
}
#[derive(Debug, Clone)]
pub struct ResolvedProfile {
pub source: ProfileSource,
pub profile: Option<ProfileMetadata>,
pub manifest: WorkerManifest,
pub manifest_snapshot: serde_json::Value,
pub raw_artifact: serde_json::Value,
}
#[derive(Debug, Clone, Default)]
pub struct ProfileResolveOptions {
pub worker_name: Option<String>,
}
impl ProfileResolveOptions {
pub fn with_worker_name(name: impl Into<String>) -> Self {
Self {
worker_name: Some(name.into()),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ProfileResolver {
workspace_base: Option<PathBuf>,
}
impl ProfileResolver {
pub fn new() -> Self {
Self::default()
}
pub fn with_workspace_base(mut self, workspace_base: impl Into<PathBuf>) -> Self {
self.workspace_base = Some(workspace_base.into());
self
}
pub fn resolve(
&self,
selector: &ProfileSelector,
options: ProfileResolveOptions,
) -> Result<ResolvedProfile, ProfileError> {
match selector {
ProfileSelector::Path { path } => self.resolve_path(
path,
ProfileSource::Path {
path: absolutize(path)?,
},
options,
),
ProfileSelector::Named { .. } | ProfileSelector::Default => {
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
path: PathBuf::from("."),
source,
})?;
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
self.resolve_from_registry(selector, &registry, options)
}
}
}
/// Resolve a registry/default selector against an already-discovered
/// registry. Callers such as SubWorkerSpawn use this to bind discovery to the
/// Worker's cwd instead of the process current directory.
pub fn resolve_from_registry(
&self,
selector: &ProfileSelector,
registry: &ProfileRegistry,
options: ProfileResolveOptions,
) -> Result<ResolvedProfile, ProfileError> {
match selector {
ProfileSelector::Path { .. } => Err(ProfileError::InvalidProfile(
"path selectors are not registry entries".into(),
)),
ProfileSelector::Named { .. } | ProfileSelector::Default => {
let entry = registry.select(selector)?.clone();
let source = ProfileSource::Registry {
source: entry.source,
name: entry.name.clone(),
path: entry.path.as_deref().map(absolutize).transpose()?,
provenance: (entry.path.is_none()).then(|| entry.provenance.clone()),
};
self.resolve_registry_entry(&entry, source, options)
}
}
}
fn resolve_registry_entry(
&self,
entry: &ProfileRegistryEntry,
source: ProfileSource,
options: ProfileResolveOptions,
) -> Result<ResolvedProfile, ProfileError> {
match &entry.artifact {
ProfileRegistryArtifact::Path(path) => self.resolve_path(path, source, options),
ProfileRegistryArtifact::Builtin { label } => {
self.resolve_builtin_profile(label, source, options)
}
}
}
fn resolve_path(
&self,
path: &Path,
source: ProfileSource,
options: ProfileResolveOptions,
) -> Result<ResolvedProfile, ProfileError> {
let absolute_path = absolutize(path)?;
let profile_dir = absolute_path
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| ProfileError::InvalidPath {
path: absolute_path.clone(),
message: "profile path has no parent directory".into(),
})?;
let profile_dir = canonicalize_existing_dir(&profile_dir)?;
let workspace_base = absolutize(
self.workspace_base
.as_deref()
.unwrap_or_else(|| Path::new(".")),
)?;
let raw_artifact = read_profile_artifact_file(&absolute_path)?;
resolve_profile_value(
source,
&profile_dir,
&workspace_base,
options,
raw_artifact.clone(),
raw_artifact,
)
}
fn resolve_builtin_profile(
&self,
label: &'static str,
source: ProfileSource,
options: ProfileResolveOptions,
) -> Result<ResolvedProfile, ProfileError> {
let workspace_base = absolutize(
self.workspace_base
.as_deref()
.unwrap_or_else(|| Path::new(".")),
)?;
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| {
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
})?;
resolve_profile_value(
source,
&workspace_base,
&workspace_base,
options,
raw_artifact.clone(),
raw_artifact,
)
}
}
fn resolve_profile_value(
source: ProfileSource,
profile_dir: &Path,
workspace_base: &Path,
options: ProfileResolveOptions,
value: serde_json::Value,
raw_artifact: serde_json::Value,
) -> Result<ResolvedProfile, ProfileError> {
if !workspace_base.is_absolute() {
return Err(ProfileError::InvalidPath {
path: workspace_base.to_path_buf(),
message: "profile workspace base must be absolute".into(),
});
}
reject_manifest_shaped_profile(&value)?;
let profile: ProfileConfig = serde_json::from_value(value.clone())
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
validate_profile_paths(&profile)?;
let worker_name = options
.worker_name
.ok_or(ProfileError::MissingRuntimeWorkerName)?;
let profile_meta = Some(ProfileMetadata {
name: profile.slug.clone().or_else(|| source_name(&source)),
description: profile.description.clone(),
format: Some(PROFILE_FORMAT_V1.to_string()),
});
let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?;
let config = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some(worker_name),
},
model: profile.model.unwrap_or_default(),
engine: profile.engine.unwrap_or_default(),
scope: profile_scope_to_config(profile.scope, workspace_base)?,
delegation_scope: profile_delegation_scope_to_config(
profile.delegation_scope,
workspace_base,
)?,
session: profile.session,
permissions: profile.permissions,
feature: profile.feature,
plugins: profile.plugins,
mcp: profile.mcp,
compaction,
web: profile.web,
memory: profile.memory.map(Into::into),
skills: profile.skills,
};
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
manifest.profile = Some(ProfileManifestSnapshot {
source: source.clone(),
profile: profile_meta.clone(),
});
let manifest_snapshot =
serde_json::to_value(&manifest).map_err(ProfileError::SnapshotSerialize)?;
Ok(ResolvedProfile {
source,
profile: profile_meta,
manifest,
manifest_snapshot,
raw_artifact,
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileMemoryConfig {
#[serde(default)]
workspace_root: Option<PathBuf>,
#[serde(default)]
query_result_limit: Option<usize>,
#[serde(default)]
query_excerpt_lines: Option<usize>,
#[serde(default)]
inject_summary: Option<bool>,
#[serde(default)]
extract_model: Option<ModelManifest>,
#[serde(default)]
extract_threshold: Option<u64>,
#[serde(default)]
extract_worker_max_turns: Option<u32>,
#[serde(default)]
consolidation_model: Option<ModelManifest>,
#[serde(default)]
consolidation_threshold_files: Option<usize>,
#[serde(default)]
consolidation_threshold_bytes: Option<u64>,
}
impl From<ProfileMemoryConfig> for MemoryConfig {
fn from(profile: ProfileMemoryConfig) -> Self {
Self {
workspace_root: profile.workspace_root,
query_result_limit: profile.query_result_limit,
query_excerpt_lines: profile.query_excerpt_lines,
inject_summary: profile.inject_summary,
workspace_id: None,
settings_revision: None,
language: None,
extract_model: profile.extract_model,
extract_threshold: profile.extract_threshold,
extract_worker_max_turns: profile.extract_worker_max_turns,
consolidation_model: profile.consolidation_model,
consolidation_threshold_files: profile.consolidation_threshold_files,
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileConfig {
#[serde(default)]
slug: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
model: Option<ModelManifest>,
#[serde(default)]
engine: Option<EngineManifestConfig>,
#[serde(default)]
scope: Option<ProfileScopeConfig>,
#[serde(default)]
delegation_scope: Option<ProfileScopeConfig>,
#[serde(default)]
session: Option<SessionConfigPartial>,
#[serde(default)]
permissions: Option<PermissionConfigPartial>,
#[serde(default)]
feature: FeatureConfigPartial,
#[serde(default)]
plugins: PluginConfig,
#[serde(default)]
mcp: McpConfig,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
#[serde(default)]
memory: Option<ProfileMemoryConfig>,
#[serde(default)]
skills: Option<SkillsConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum ProfileScopeConfig {
Table(ProfileScopeTable),
String(ProfileScopeIntent),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct ProfileScopeTable {
intent: ProfileScopeIntent,
#[serde(default)]
deny_write: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ProfileScopeIntent {
WorkspaceRead,
WorkspaceWrite,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RatioCompaction {
#[allow(dead_code)]
kind: String,
#[serde(default)]
threshold: Option<f64>,
#[serde(default, alias = "request")]
request_threshold: Option<f64>,
#[serde(default, alias = "worker")]
worker_context_max_tokens: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct ProfileRegistryDocument {
#[serde(default)]
default: Option<String>,
#[serde(default, alias = "entries")]
profile: BTreeMap<String, ProfileEntryConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ProfileEntryConfig {
Path(String),
Table {
path: PathBuf,
#[serde(default)]
description: Option<String>,
},
}
impl ProfileEntryConfig {
fn into_parts(self) -> (PathBuf, Option<String>) {
match self {
Self::Path(path) => (PathBuf::from(path), None),
Self::Table { path, description } => (path, description),
}
}
}
fn load_profile_registry_file(
registry: &mut ProfileRegistry,
source: ProfileRegistrySource,
path: &Path,
) -> Result<(), ProfileError> {
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(path).map_err(|source| ProfileError::ConfigRead {
path: path.to_path_buf(),
source,
})?;
let config: ProfileRegistryDocument =
toml::from_str(&content).map_err(|source| ProfileError::ConfigParse {
path: path.to_path_buf(),
source,
})?;
let base = path.parent().unwrap_or_else(|| Path::new("."));
for (name, entry_config) in config.profile {
let (entry_path, description) = entry_config.into_parts();
registry.push_entry(ProfileRegistryEntry::path(
source,
name,
join_if_relative(base, &entry_path),
description,
));
}
if let Some(default) = config.default {
let (default_source, default_name) = parse_profile_ref(&default);
registry.set_default(ProfileDefault {
source: default_source.or(Some(source)),
name: default_name,
});
}
Ok(())
}
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
for profile in BUILTIN_PROFILES {
registry.push_entry(ProfileRegistryEntry::embedded(
ProfileRegistrySource::Builtin,
profile.name,
profile.label,
Some(profile.description.into()),
));
}
}
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
if let Some((prefix, name)) = raw.split_once(':')
&& let Some(source) = ProfileRegistrySource::parse(prefix)
{
return (Some(source), name.to_string());
}
(None, raw.to_string())
}
fn read_profile_artifact_file(path: &Path) -> Result<serde_json::Value, ProfileError> {
let content = std::fs::read_to_string(path).map_err(|source| ProfileError::ConfigRead {
path: path.to_path_buf(),
source,
})?;
match path.extension().and_then(|s| s.to_str()) {
Some("json") => serde_json::from_str(&content)
.map_err(|source| ProfileError::ProfileDeserialize { source }),
Some("toml") => {
let value: toml::Value =
toml::from_str(&content).map_err(|source| ProfileError::ConfigParse {
path: path.to_path_buf(),
source,
})?;
serde_json::to_value(value).map_err(ProfileError::SnapshotSerialize)
}
other => Err(ProfileError::UnsupportedProfileType {
path: path.to_path_buf(),
message: format!(
"unsupported profile extension {}; Profiles must be .json or .toml artifacts",
other.map_or("<none>".to_string(), |s| format!(".{s}"))
),
}),
}
}
fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
let mut value = builtin_base_profile_artifact();
match label {
"builtin:companion" | "companion" => {
apply_role_profile(
&mut value,
"companion",
"Workspace companion profile.",
"workspace_write",
true,
true,
true,
true,
);
Some(value)
}
"builtin:intake" | "intake" => {
apply_role_profile(
&mut value,
"intake",
"Ticket intake profile.",
"workspace_write",
true,
true,
true,
false,
);
Some(value)
}
"builtin:orchestrator" | "orchestrator" => {
apply_role_profile(
&mut value,
"orchestrator",
"Ticket orchestrator profile.",
"workspace_write",
true,
true,
true,
false,
);
Some(value)
}
"builtin:coder" | "coder" => {
apply_role_profile(
&mut value,
"coder",
"Ticket implementation coder profile.",
"workspace_write",
true,
true,
true,
true,
);
Some(value)
}
"builtin:reviewer" | "reviewer" => {
apply_role_profile(
&mut value,
"reviewer",
"Ticket review profile.",
"workspace_read",
true,
true,
true,
false,
);
Some(value)
}
"builtin:memory-consolidation" | "memory-consolidation" => {
value["slug"] = serde_json::Value::String("memory-consolidation".to_string());
value["description"] =
serde_json::Value::String("Memory staging consolidation profile.".to_string());
value["feature"]["task"] = serde_json::json!({ "enabled": false });
value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true });
value["feature"]["web"] = serde_json::json!({ "enabled": false });
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": false });
value["feature"]["objective"] = serde_json::json!({ "enabled": false });
value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false });
Some(value)
}
_ => None,
}
}
fn builtin_base_profile_artifact() -> serde_json::Value {
serde_json::json!({
"slug": "default",
"description": "Default Yoi coding profile.",
"model": { "ref": "codex-oauth/gpt-5.5" },
"session": { "record_event_trace": true },
"engine": { "reasoning": "high" },
"compaction": {
"kind": "tokens",
"threshold": 240000,
"request_threshold": 270000,
"worker_context_max_tokens": 100000
},
"feature": {
"task": { "enabled": true },
"memory": { "enabled": true },
"web": { "enabled": true },
"image": { "enabled": true },
"sub_worker": { "enabled": true },
"worker": { "enabled": false },
"objective": { "enabled": true },
"ticket": { "enabled": true, "authoring": true, "thread": true }
},
"memory": {
"extract_threshold": 50000,
"consolidation_threshold_files": 5,
"consolidation_threshold_bytes": 50000
},
"web": {
"enabled": true,
"search": {
"provider": "brave",
"api_key_secret": "web/brave/default"
}
}
})
}
#[allow(clippy::too_many_arguments)]
fn apply_role_profile(
value: &mut serde_json::Value,
slug: &str,
description: &str,
_scope: &str,
task: bool,
memory: bool,
web: bool,
sub_worker: bool,
) {
value["slug"] = serde_json::Value::String(slug.to_string());
value["description"] = serde_json::Value::String(description.to_string());
value["feature"]["task"] = serde_json::json!({ "enabled": task });
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
value["feature"]["web"] = serde_json::json!({ "enabled": web });
value["feature"]["image"] = serde_json::json!({ "enabled": true });
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
value["feature"]["worker"] = serde_json::json!({
"enabled": matches!(slug, "companion" | "orchestrator"),
"direct_spawn": slug != "orchestrator"
});
value["feature"]["manage_workdir"] = serde_json::json!({
"enabled": matches!(slug, "companion" | "orchestrator")
});
value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" });
let ticket = match slug {
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
"intake" => {
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
}
"orchestrator" => {
serde_json::json!({ "enabled": true, "thread": true, "workflow": true })
}
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
};
value["feature"]["ticket"] = ticket;
let merge_request = match slug {
"coder" => serde_json::json!({
"show": true,
"open": true,
"review": false,
"readiness_check": false,
"complete": false
}),
"reviewer" => serde_json::json!({
"show": true,
"open": false,
"review": true,
"readiness_check": false,
"complete": false
}),
"orchestrator" => serde_json::json!({
"show": true,
"open": false,
"review": false,
"readiness_check": true,
"complete": true
}),
_ => serde_json::json!({
"show": false,
"open": false,
"review": false,
"readiness_check": false,
"complete": false
}),
};
value["feature"]["merge_request"] = merge_request;
}
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
let Some(map) = value.as_object() else {
return Err(ProfileError::InvalidProfile(
"Profile artifact must be an object".into(),
));
};
for key in ["manifest", "config"] {
if map.contains_key(key) {
return Err(ProfileError::InvalidProfile(format!(
"field `{key}` is a complete Manifest artifact boundary and is not allowed in reusable Profiles; use --manifest for complete Manifests"
)));
}
}
if map.contains_key("worker") {
return Err(ProfileError::InvalidProfile("field `worker` is runtime-bound and is not allowed in reusable Profiles; pass the Worker name via CLI/TUI runtime inputs".into()));
}
if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) {
for key in ["allow", "deny"] {
if scope.contains_key(key) {
return Err(ProfileError::InvalidProfile(format!(
"field `scope.{key}` grants concrete authority and is not allowed in reusable Profiles; use profile scope intents"
)));
}
}
}
Ok(())
}
fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
if let Some(model) = &profile.model {
reject_absolute_auth_file(&model.auth, "model.auth.file")?;
}
if let Some(compaction) = &profile.compaction
&& let Some(model) = compaction.get("model")
{
let model: ModelManifest = serde_json::from_value(model.clone())
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
}
if let Some(memory) = &profile.memory
&& let Some(root) = &memory.workspace_root
&& root.is_absolute()
{
return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into()));
}
if let Some(skills) = &profile.skills {
for dir in &skills.directories {
if dir.is_absolute() {
return Err(ProfileError::InvalidProfile(
"field `skills.directories` must be profile-relative in reusable Profiles"
.into(),
));
}
}
}
for server in &profile.mcp.stdio_servers {
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd
&& path.is_absolute()
{
return Err(ProfileError::InvalidProfile(
"field `mcp.stdio_server.cwd.path` must be profile-relative in reusable Profiles"
.into(),
));
}
}
Ok(())
}
fn reject_absolute_auth_file(
auth: &Option<AuthRef>,
field: &'static str,
) -> Result<(), ProfileError> {
if let Some(AuthRef::ApiKey { file: Some(file) }) = auth
&& file.is_absolute()
{
return Err(ProfileError::InvalidProfile(format!(
"field `{field}` is a resolved path and is not allowed in reusable Profiles"
)));
}
Ok(())
}
fn profile_scope_to_config(
scope: Option<ProfileScopeConfig>,
workspace_base: &Path,
) -> Result<ScopeConfig, ProfileError> {
profile_scope_intent_to_config(scope, workspace_base, None, "scope")
}
fn profile_delegation_scope_to_config(
scope: Option<ProfileScopeConfig>,
workspace_base: &Path,
) -> Result<ScopeConfig, ProfileError> {
profile_scope_intent_to_config(scope, workspace_base, None, "delegation_scope")
}
fn profile_scope_intent_to_config(
scope: Option<ProfileScopeConfig>,
workspace_base: &Path,
default_intent: Option<ProfileScopeIntent>,
field: &'static str,
) -> Result<ScopeConfig, ProfileError> {
let (intent, deny_write) = match scope {
Some(ProfileScopeConfig::Table(table)) => (Some(table.intent), table.deny_write),
Some(ProfileScopeConfig::String(intent)) => (Some(intent), Vec::new()),
None => (default_intent, Vec::new()),
};
let Some(intent) = intent else {
return Ok(ScopeConfig::default());
};
let permission = match intent {
ProfileScopeIntent::WorkspaceRead => Permission::Read,
ProfileScopeIntent::WorkspaceWrite => Permission::Write,
};
let mut deny = Vec::new();
for path in deny_write {
if path.is_absolute() {
return Err(ProfileError::InvalidProfile(format!(
"field `{field}.deny_write` must be workspace-relative in reusable Profiles"
)));
}
deny.push(ScopeRule {
target: workspace_base.join(path),
permission: Permission::Write,
recursive: true,
});
}
Ok(ScopeConfig {
allow: vec![ScopeRule {
target: workspace_base.to_path_buf(),
permission,
recursive: true,
}],
deny,
})
}
fn profile_compaction_to_partial(
value: Option<serde_json::Value>,
model: &Option<ModelManifest>,
) -> Result<Option<CompactionConfigPartial>, ProfileError> {
let Some(value) = value else {
return Ok(None);
};
let Some(kind) = value.get("kind").and_then(|v| v.as_str()) else {
return serde_json::from_value(value)
.map(Some)
.map_err(|source| ProfileError::ProfileDeserialize { source });
};
match kind {
"tokens" => {
let mut obj = value.as_object().cloned().unwrap_or_default();
obj.remove("kind");
serde_json::from_value(serde_json::Value::Object(obj))
.map(Some)
.map_err(|source| ProfileError::ProfileDeserialize { source })
}
"ratio" => {
let ratio: RatioCompaction = serde_json::from_value(value)
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
let context = model_context_window(model.as_ref()).ok_or_else(|| ProfileError::InvalidProfile("compact.ratio requires model.context_window/max_context_window or a known model ref; use compact.tokens for explicit token values".into()))?;
Ok(Some(CompactionConfigPartial {
threshold: ratio.threshold.map(|r| ratio_tokens(context, r)),
request_threshold: ratio.request_threshold.map(|r| ratio_tokens(context, r)),
worker_context_max_tokens: ratio
.worker_context_max_tokens
.map(|r| ratio_tokens(context, r)),
..Default::default()
}))
}
other => Err(ProfileError::InvalidProfile(format!(
"unknown compaction helper kind `{other}`"
))),
}
}
fn ratio_tokens(context: u64, ratio: f64) -> u64 {
((context as f64) * ratio).floor() as u64
}
fn model_context_window(model: Option<&ModelManifest>) -> Option<u64> {
let model = model?;
if let Some(max) = model.max_context_window {
return Some(model.context_window.map_or(max, |ctx| ctx.min(max)));
}
if let Some(ctx) = model.context_window {
return Some(ctx);
}
builtin_model_context_window(model.ref_.as_deref()?)
}
fn builtin_model_context_window(reference: &str) -> Option<u64> {
let (provider, model_id) = reference.split_once('/')?;
let parsed: toml::Value = toml::from_str(BUILTIN_MODEL_CATALOG).ok()?;
for entry in parsed.get("model")?.as_array()? {
let table = entry.as_table()?;
if table.get("provider")?.as_str()? == provider && table.get("id")?.as_str()? == model_id {
let context = table.get("context_window")?.as_integer()? as u64;
let max = table
.get("max_context_window")
.and_then(|v| v.as_integer())
.map(|v| v as u64);
return Some(max.map_or(context, |max| context.min(max)));
}
}
None
}
fn source_name(source: &ProfileSource) -> Option<String> {
match source {
ProfileSource::Path { path } => path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string),
ProfileSource::Registry { name, .. } => Some(name.clone()),
ProfileSource::Archive { source, .. } => Path::new(source)
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string),
}
}
fn canonicalize_existing_dir(path: &Path) -> Result<PathBuf, ProfileError> {
path.canonicalize()
.map_err(|source| ProfileError::CommandIo {
path: path.to_path_buf(),
source,
})
}
fn absolutize(path: &Path) -> Result<PathBuf, ProfileError> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()
.map_err(|source| ProfileError::CommandIo {
path: PathBuf::from("."),
source,
})?
.join(path))
}
}
fn join_if_relative(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
pub fn resolve_profile_artifact(
source: ProfileSource,
base_dir: &Path,
raw_artifact: serde_json::Value,
) -> Result<ResolvedProfile, ProfileError> {
resolve_profile_artifact_value(raw_artifact, source, base_dir, "artifact-worker")
}
pub fn resolve_profile_artifact_value(
raw_artifact: serde_json::Value,
source: ProfileSource,
base_dir: &Path,
worker_name: &str,
) -> Result<ResolvedProfile, ProfileError> {
resolve_profile_value(
source,
base_dir,
base_dir,
ProfileResolveOptions::with_worker_name(worker_name),
raw_artifact.clone(),
raw_artifact,
)
}
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
#[error("invalid profile path {}: {message}", .path.display())]
InvalidPath { path: PathBuf, message: String },
#[error("unsupported profile type {}: {message}", .path.display())]
UnsupportedProfileType { path: PathBuf, message: String },
#[error("failed to access profile path {}: {source}", .path.display())]
CommandIo {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to read profile registry config {}: {source}", .path.display())]
ConfigRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse profile registry config {}: {source}", .path.display())]
ConfigParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("no default profile is configured")]
NoDefaultProfile,
#[error("profile resolution requires an explicit runtime Worker name")]
MissingRuntimeWorkerName,
#[error("profile not found: {selector}")]
ProfileNotFound { selector: String },
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
AmbiguousProfileName { name: String, matches: Vec<String> },
#[error("invalid Profile artifact: {0}")]
InvalidProfile(String),
#[error("failed to decode Profile: {source}")]
ProfileDeserialize {
#[source]
source: serde_json::Error,
},
#[error("failed to resolve Profile into Manifest: {0}")]
ManifestResolve(#[source] ResolveError),
#[error("failed to serialize resolved manifest snapshot: {0}")]
SnapshotSerialize(#[source] serde_json::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ReasoningControl, ReasoningEffort, SchemeKind};
use tempfile::TempDir;
fn write_profile(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn parse_cli_preserves_paths_and_source_qualified_names() {
assert!(matches!(
ProfileSelector::parse_cli("./coder.toml"),
ProfileSelector::Path { .. }
));
assert_eq!(
ProfileSelector::parse_cli("project:coder"),
ProfileSelector::source_named(ProfileRegistrySource::Project, "coder")
);
assert_eq!(
ProfileSelector::parse_cli("coder"),
ProfileSelector::named("coder")
);
assert_eq!(
ProfileSelector::parse_cli("default"),
ProfileSelector::Default
);
}
#[test]
fn builtin_profiles_do_not_define_an_implicit_default() {
let registry = ProfileDiscovery::with_sources(None, None)
.discover()
.unwrap();
assert!(matches!(
registry.default_entry(),
Err(ProfileError::NoDefaultProfile)
));
assert!(matches!(
registry.select(&ProfileSelector::Default),
Err(ProfileError::NoDefaultProfile)
));
}
#[test]
fn builtin_role_profiles_are_registered_and_resolve() {
let tmp = TempDir::new().unwrap();
let registry = ProfileDiscovery::with_sources(None, None)
.discover()
.unwrap();
assert!(!registry.entries().is_empty());
for entry in registry.entries() {
assert_eq!(entry.source, ProfileRegistrySource::Builtin);
assert_eq!(entry.path, None);
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(
ProfileRegistrySource::Builtin,
entry.name.as_str(),
),
ProfileResolveOptions::with_worker_name("role-worker"),
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "role-worker");
}
}
#[test]
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
let tmp = TempDir::new().unwrap();
for (field, value) in [
("workspace_id", serde_json::json!("workspace-a")),
("settings_revision", serde_json::json!(2)),
("language", serde_json::json!("Japanese")),
] {
let artifact = serde_json::json!({ "memory": { (field): value } });
let error = resolve_profile_artifact_value(
artifact,
ProfileSource::Registry {
source: ProfileRegistrySource::Builtin,
name: "test".to_string(),
path: None,
provenance: None,
},
tmp.path(),
"test-worker",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error for {field}: {error}"
);
}
}
#[test]
fn builtin_companion_can_manage_workdirs() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"),
ProfileResolveOptions::with_worker_name("companion-worker"),
)
.unwrap();
assert!(resolved.manifest.feature.manage_workdir.enabled);
}
#[test]
fn profile_resolution_requires_runtime_worker_name() {
let tmp = TempDir::new().unwrap();
let err = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"),
ProfileResolveOptions::default(),
)
.unwrap_err();
assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
}
#[test]
fn resolves_toml_profile_with_runtime_worker_name_and_scope_intent() {
let tmp = TempDir::new().unwrap();
let profile = write_profile(
tmp.path(),
"coder.toml",
r#"
slug = "coder"
scope = "workspace_read"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[engine]
reasoning = "high"
"#,
);
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!(
resolved.manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::High))
);
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
assert!(resolved.manifest.delegation_scope.allow.is_empty());
assert_eq!(
resolved.manifest.scope.allow[0].permission,
Permission::Read
);
assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(),
Some("coder")
);
}
#[test]
fn profile_artifact_resolves_named_mcp_stdio_config_without_starting_command() {
let tmp = TempDir::new().unwrap();
let profile = write_profile(
tmp.path(),
"mcp.json",
r#"{
"slug": "mcp",
"model": { "scheme": "anthropic", "model_id": "claude-sonnet-4-20250514" },
"mcp": {
"stdio_server": [
{
"name": "filesystem",
"command": "definitely-not-spawned-during-profile-resolution",
"args": ["--root", "."],
"cwd": { "kind": "path", "path": "servers" },
"env": {
"inherit": ["PATH"],
"set": {
"SAFE_MODE": { "kind": "literal", "value": "1" },
"API_TOKEN": { "kind": "secret_ref", "ref": "providers/mcp-token" },
"FROM_ENV": { "kind": "env_ref", "name": "MCP_TOKEN" }
}
}
}
]
}
}"#,
);
std::fs::create_dir(tmp.path().join("servers")).unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap();
let server = &resolved.manifest.mcp.stdio_servers[0];
assert_eq!(server.name, "filesystem");
assert_eq!(
server.command,
"definitely-not-spawned-during-profile-resolution"
);
assert!(matches!(
server.cwd,
Some(McpStdioCwdPolicy::Path { ref path }) if path == &tmp.path().join("servers")
));
assert!(matches!(
server.env.set["API_TOKEN"],
crate::McpEnvValue::SecretRef { .. }
));
}
#[test]
fn resolves_profile_feature_flags_without_runtime_state() {
let tmp = TempDir::new().unwrap();
let profile = write_profile(
tmp.path(),
"feature.toml",
r#"
slug = "feature"
scope = "workspace_read"
delegation_scope = "workspace_write"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[feature.task]
enabled = true
[feature.memory]
enabled = false
[feature.web]
enabled = true
[feature.sub_worker]
enabled = true
[feature.ticket]
enabled = true
authoring = false
thread = false
intake = false
[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false
[feature.orchestration]
enabled = false
"#,
);
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap();
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert!(resolved.manifest.feature.task.enabled);
assert!(!resolved.manifest.feature.memory.enabled);
assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.feature.ticket.enabled);
assert!(!resolved.manifest.feature.ticket.authoring);
assert!(!resolved.manifest.feature.ticket.thread);
assert!(!resolved.manifest.feature.ticket.intake);
assert_eq!(
resolved.manifest.feature.merge_request,
crate::MergeRequestFeatureConfig {
show: true,
review: true,
..Default::default()
}
);
assert!(!resolved.manifest.feature.orchestration.enabled);
assert_eq!(
resolved.manifest.delegation_scope.allow[0].target,
workspace
);
}
#[test]
fn compact_tokens_uses_explicit_context_limits() {
let tmp = TempDir::new().unwrap();
let profile = write_profile(
tmp.path(),
"ratio.toml",
r#"
[model]
ref = "codex-oauth/gpt-5.5"
[compaction]
kind = "tokens"
threshold = 136000
request_threshold = 204000
worker_context_max_tokens = 68000
"#,
);
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_worker_name("p"),
)
.unwrap();
let c = resolved.manifest.compaction.unwrap();
assert_eq!(c.threshold, Some(136000));
assert_eq!(c.request_threshold, Some(204000));
assert_eq!(c.worker_context_max_tokens, 68000);
}
#[test]
fn rejects_manifest_shaped_runtime_and_authority_fields() {
for (value, needle) in [
(serde_json::json!({"manifest": {}}), "manifest"),
(serde_json::json!({"config": {}}), "config"),
(serde_json::json!({"worker": {"name": "bad"}}), "worker"),
(
serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"allow": []}}),
"scope.allow",
),
(
serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"deny": []}}),
"scope.deny",
),
] {
let err = resolve_profile_artifact(
ProfileSource::Path {
path: PathBuf::from("/profiles/bad.json"),
},
Path::new("/workspace"),
value,
)
.unwrap_err();
assert!(err.to_string().contains(needle), "unexpected error: {err}");
}
}
#[test]
fn rejects_absolute_profile_paths() {
let err = resolve_profile_artifact(ProfileSource::Path { path: PathBuf::from("/profiles/bad.json") }, Path::new("/workspace"), serde_json::json!({"model": { "scheme": "anthropic", "model_id": "m", "auth": {"kind":"api_key", "file":"/secret/key"} }, "scope": "workspace_write"})).unwrap_err();
assert!(err.to_string().contains("model.auth.file"));
}
#[test]
fn unsupported_profile_extension_has_clear_diagnostic() {
let tmp = TempDir::new().unwrap();
let path = write_profile(tmp.path(), "legacy.txt", "{}");
let err = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(path),
ProfileResolveOptions::default(),
)
.unwrap_err();
assert!(matches!(err, ProfileError::UnsupportedProfileType { .. }));
assert!(
err.to_string()
.contains("Profiles must be .json or .toml artifacts")
);
}
#[test]
fn explicit_discovery_sources_preserve_non_workspace_tooling_contract() {
let tmp = TempDir::new().unwrap();
let user_config = tmp.path().join("profiles.toml");
let project_dir = tmp.path().join("project/.yoi");
std::fs::create_dir_all(&project_dir).unwrap();
let project_config = project_dir.join("profiles.toml");
std::fs::write(
&user_config,
"default = \"coder\"\n[profile]\ncoder = \"profiles/user-coder.toml\"\n",
)
.unwrap();
std::fs::write(&project_config, "default = \"project:coder\"\n[profile.coder]\npath = \"profiles/project-coder.toml\"\ndescription = \"Project coder\"\n").unwrap();
let registry = ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
.discover()
.unwrap();
let default = registry.default_entry().unwrap();
assert_eq!(default.source, ProfileRegistrySource::Project);
assert_eq!(default.name, "coder");
assert!(
default
.path
.as_ref()
.unwrap()
.ends_with("profiles/project-coder.toml")
);
}
#[test]
fn default_marks_direct_profile_entry() {
let tmp = TempDir::new().unwrap();
let project_dir = tmp.path().join("project/.yoi");
std::fs::create_dir_all(&project_dir).unwrap();
let project_config = project_dir.join("profiles.toml");
std::fs::write(
&project_config,
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n",
)
.unwrap();
let registry = ProfileDiscovery::with_sources(None, Some(project_config))
.discover()
.unwrap();
let default = registry.default_entry().unwrap();
assert_eq!(default.source, ProfileRegistrySource::Project);
assert_eq!(default.name, "coder");
assert!(default.is_default);
assert_eq!(
registry
.entries()
.iter()
.filter(|entry| entry.is_default)
.count(),
1
);
}
#[test]
fn unqualified_ambiguous_names_fail_closed() {
let mut registry = ProfileRegistry::default();
registry.push_entry(ProfileRegistryEntry::path(
ProfileRegistrySource::User,
"coder".to_string(),
PathBuf::from("/user/coder.toml"),
None,
));
registry.push_entry(ProfileRegistryEntry::path(
ProfileRegistrySource::Project,
"coder".to_string(),
PathBuf::from("/project/coder.toml"),
None,
));
let err = registry
.select(&ProfileSelector::named("coder"))
.unwrap_err();
assert!(matches!(err, ProfileError::AmbiguousProfileName { .. }));
let selected = registry
.select(&ProfileSelector::source_named(
ProfileRegistrySource::Project,
"coder",
))
.unwrap();
assert_eq!(
selected.path.as_deref(),
Some(Path::new("/project/coder.toml"))
);
}
}