merge: manifest profile discovery and picker
This commit is contained in:
@@ -27,11 +27,11 @@ pub struct SpawnConfig {
|
||||
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
|
||||
/// 名前との突き合わせに使う。
|
||||
pub pod_name: String,
|
||||
/// Optional Nix profile path. When present the child is launched with
|
||||
/// Optional Nix profile selector. When present the child is launched with
|
||||
/// `--profile` and the TOML overlay is not passed; the Pod name is supplied
|
||||
/// through `--profile-pod-name` so profile evaluation stays separate from
|
||||
/// manifest layer merging and from `--pod` restore semantics.
|
||||
pub profile_path: Option<PathBuf>,
|
||||
pub profile: Option<String>,
|
||||
/// `--overlay` で pod に渡す TOML 文字列。
|
||||
pub overlay_toml: String,
|
||||
/// pod の current_dir。
|
||||
@@ -117,16 +117,16 @@ where
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.process_group(0);
|
||||
if let Some(profile_path) = &config.profile_path {
|
||||
if let Some(profile) = &config.profile {
|
||||
command
|
||||
.arg("--profile")
|
||||
.arg(profile_path)
|
||||
.arg(profile)
|
||||
.arg("--profile-pod-name")
|
||||
.arg(&config.pod_name);
|
||||
} else {
|
||||
command.arg("--overlay").arg(&config.overlay_toml);
|
||||
}
|
||||
if config.resume_by_pod_name && config.profile_path.is_none() {
|
||||
if config.resume_by_pod_name && config.profile.is_none() {
|
||||
command.arg("--pod").arg(&config.pod_name);
|
||||
}
|
||||
if let Some(id) = config.resume_from {
|
||||
|
||||
@@ -18,8 +18,9 @@ pub use paths::{
|
||||
user_manifest_path, user_manifest_path_from_env, user_manifest_path_with_env_override,
|
||||
};
|
||||
pub use profile::{
|
||||
NixProfileResolver, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileSelector,
|
||||
ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||
NixProfileResolver, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata,
|
||||
ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileSelector, ProfileSource,
|
||||
ResolvedProfile, resolve_profile_artifact,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{Scope, ScopeError, SharedScope};
|
||||
|
||||
@@ -33,6 +33,9 @@ use std::path::PathBuf;
|
||||
/// auto-discovered user manifest path.
|
||||
pub const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
|
||||
|
||||
/// Environment variable that points at installed project resources.
|
||||
pub const RESOURCE_DIR_ENV: &str = "INSOMNIA_RESOURCE_DIR";
|
||||
|
||||
/// 設定ディレクトリ。`manifest.toml`, `providers.toml`, `models.toml`,
|
||||
/// `prompts/` などが置かれる。
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
@@ -114,6 +117,32 @@ pub fn user_prompts_dir() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("prompts"))
|
||||
}
|
||||
|
||||
/// Root resource directory used for bundled prompts/Nix support files.
|
||||
pub fn resource_dir() -> Option<PathBuf> {
|
||||
if let Some(p) = env_path(RESOURCE_DIR_ENV) {
|
||||
return Some(p);
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(prefix) = exe.parent().and_then(|bin| bin.parent()) {
|
||||
let installed = prefix.join("share").join("insomnia").join("resources");
|
||||
if installed.exists() {
|
||||
return Some(installed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join("resources"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bundled profile registry directory. Missing directories are treated as an
|
||||
/// empty builtin registry by discovery.
|
||||
pub fn builtin_profiles_dir() -> Option<PathBuf> {
|
||||
Some(resource_dir()?.join("nix").join("profiles"))
|
||||
}
|
||||
|
||||
/// `<config_dir>/prompts.toml` — user prompt pack。
|
||||
pub fn user_pack_file() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("prompts.toml"))
|
||||
@@ -192,6 +221,7 @@ mod tests {
|
||||
"INSOMNIA_DATA_DIR",
|
||||
"INSOMNIA_RUNTIME_DIR",
|
||||
"INSOMNIA_USER_MANIFEST",
|
||||
"INSOMNIA_RESOURCE_DIR",
|
||||
"INSOMNIA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
|
||||
+687
-13
@@ -4,34 +4,318 @@
|
||||
//! resolved artifact. Rust consumes the evaluated JSON artifact directly and
|
||||
//! validates it into the existing [`crate::PodManifest`] runtime contract.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PodManifest, PodManifestConfig, ResolveError};
|
||||
use crate::{PodManifest, PodManifestConfig, ResolveError, paths};
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1";
|
||||
|
||||
/// Registry source for discovered profiles.
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
|
||||
/// User selection of a profile source.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProfileSelector {
|
||||
/// A local Nix expression evaluated with `nix eval --json --file <path>`.
|
||||
Path { path: PathBuf },
|
||||
/// A named profile discovered from builtin/user/project registries.
|
||||
Named {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
source: Option<ProfileRegistrySource>,
|
||||
name: String,
|
||||
},
|
||||
/// The effective default from the discovered profile registry.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the CLI/TUI `--profile` argument.
|
||||
///
|
||||
/// `path:<path>` always selects an explicit path. `builtin:<name>`,
|
||||
/// `user:<name>`, and `project:<name>` require the given registry source.
|
||||
/// Unqualified path-like values (containing `/`, starting with `.`, or ending
|
||||
/// in `.nix`) remain compatible with the original explicit-path flow; other
|
||||
/// values are discovered names. `default` asks discovery for the effective
|
||||
/// default profile.
|
||||
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(':') {
|
||||
if let Some(source) = ProfileRegistrySource::parse(prefix) {
|
||||
return Self::source_named(source, name);
|
||||
}
|
||||
}
|
||||
if raw.contains('/') || raw.starts_with('.') || raw.ends_with(".nix") {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile source recorded with a resolved artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProfileSource {
|
||||
Path { path: PathBuf },
|
||||
Path {
|
||||
path: PathBuf,
|
||||
},
|
||||
Registry {
|
||||
source: ProfileRegistrySource,
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// One profile discovered from a registry source.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProfileRegistryEntry {
|
||||
pub source: ProfileRegistrySource,
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub description: Option<String>,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
impl ProfileRegistryEntry {
|
||||
pub fn qualified_name(&self) -> String {
|
||||
format!("{}:{}", self.source, self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovered profile registry. User/project manifests contribute only profile
|
||||
/// discovery metadata (entries, aliases, defaults); those files are not merged
|
||||
/// into the selected profile's runtime manifest.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProfileRegistry {
|
||||
entries: Vec<ProfileRegistryEntry>,
|
||||
aliases: Vec<ProfileAlias>,
|
||||
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::InvalidArtifact(
|
||||
"path selectors are not registry entries".to_string(),
|
||||
)),
|
||||
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 alias_matches: Vec<_> = self
|
||||
.aliases
|
||||
.iter()
|
||||
.filter(|alias| alias.name == name && source.is_none_or(|s| s == alias.source))
|
||||
.collect();
|
||||
match alias_matches.as_slice() {
|
||||
[alias] => return self.select_named(alias.target_source, &alias.target_name),
|
||||
[] => {}
|
||||
_ => {
|
||||
return Err(ProfileError::AmbiguousProfileName {
|
||||
name: name.to_string(),
|
||||
matches: alias_matches
|
||||
.iter()
|
||||
.map(|alias| format!("{}:{}", alias.source, alias.name))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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: match source {
|
||||
Some(source) => format!("{source}:{name}"),
|
||||
None => name.to_string(),
|
||||
},
|
||||
}),
|
||||
_ => 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 push_alias(&mut self, alias: ProfileAlias) {
|
||||
self.aliases.push(alias);
|
||||
}
|
||||
|
||||
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 ProfileAlias {
|
||||
source: ProfileRegistrySource,
|
||||
name: String,
|
||||
target_source: Option<ProfileRegistrySource>,
|
||||
target_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProfileDefault {
|
||||
source: Option<ProfileRegistrySource>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Filesystem-backed profile discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProfileDiscovery {
|
||||
builtin_dir: Option<PathBuf>,
|
||||
user_manifest: Option<PathBuf>,
|
||||
project_manifest: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ProfileDiscovery {
|
||||
pub fn for_cwd(cwd: &Path) -> Self {
|
||||
Self {
|
||||
builtin_dir: paths::builtin_profiles_dir(),
|
||||
user_manifest: paths::user_manifest_path_with_env_override(),
|
||||
project_manifest: crate::find_project_manifest_from(cwd),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_sources(
|
||||
builtin_dir: Option<PathBuf>,
|
||||
user_manifest: Option<PathBuf>,
|
||||
project_manifest: Option<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
builtin_dir,
|
||||
user_manifest,
|
||||
project_manifest,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn discover(&self) -> Result<ProfileRegistry, ProfileError> {
|
||||
let mut registry = ProfileRegistry::default();
|
||||
if let Some(dir) = &self.builtin_dir {
|
||||
discover_profile_dir(&mut registry, ProfileRegistrySource::Builtin, dir)?;
|
||||
}
|
||||
if let Some(path) = &self.user_manifest {
|
||||
load_profile_config_manifest(&mut registry, ProfileRegistrySource::User, path)?;
|
||||
}
|
||||
if let Some(path) = &self.project_manifest {
|
||||
load_profile_config_manifest(&mut registry, ProfileRegistrySource::Project, path)?;
|
||||
}
|
||||
registry.mark_default_flags();
|
||||
Ok(registry)
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata optionally emitted by `mkProfile`.
|
||||
@@ -93,11 +377,36 @@ impl NixProfileResolver {
|
||||
|
||||
pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> {
|
||||
match selector {
|
||||
ProfileSelector::Path { path } => self.resolve_path(path),
|
||||
ProfileSelector::Path { path } => self.resolve_path(
|
||||
path,
|
||||
ProfileSource::Path {
|
||||
path: absolutize(path)?,
|
||||
},
|
||||
),
|
||||
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()?;
|
||||
let entry = registry.select(selector)?.clone();
|
||||
self.resolve_path(
|
||||
&entry.path,
|
||||
ProfileSource::Registry {
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
path: absolutize(&entry.path)?,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &Path) -> Result<ResolvedProfile, ProfileError> {
|
||||
fn resolve_path(
|
||||
&self,
|
||||
path: &Path,
|
||||
source: ProfileSource,
|
||||
) -> Result<ResolvedProfile, ProfileError> {
|
||||
let absolute_path = absolutize(path)?;
|
||||
let base_dir = absolute_path
|
||||
.parent()
|
||||
@@ -137,17 +446,11 @@ impl NixProfileResolver {
|
||||
|
||||
let raw_artifact: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).map_err(|source| ProfileError::JsonParse {
|
||||
path: absolute_path.clone(),
|
||||
path: absolute_path,
|
||||
source,
|
||||
})?;
|
||||
|
||||
resolve_profile_artifact(
|
||||
ProfileSource::Path {
|
||||
path: absolute_path,
|
||||
},
|
||||
&base_dir,
|
||||
raw_artifact,
|
||||
)
|
||||
resolve_profile_artifact(source, &base_dir, raw_artifact)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +513,150 @@ impl ProfileEnvelope {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ProfileConfigDocument {
|
||||
#[serde(default)]
|
||||
profiles: Option<ProfilesConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ProfilesConfig {
|
||||
#[serde(default)]
|
||||
default: Option<String>,
|
||||
#[serde(default, alias = "entries")]
|
||||
profile: BTreeMap<String, ProfileEntryConfig>,
|
||||
#[serde(default, alias = "aliases")]
|
||||
alias: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[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_config_manifest(
|
||||
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 document: ProfileConfigDocument =
|
||||
toml::from_str(&content).map_err(|source| ProfileError::ConfigParse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let Some(config) = document.profiles else {
|
||||
return Ok(());
|
||||
};
|
||||
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 {
|
||||
source,
|
||||
name,
|
||||
path: join_if_relative(base, &entry_path),
|
||||
description,
|
||||
is_default: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (name, target) in config.alias {
|
||||
let (target_source, target_name) = parse_profile_ref(&target);
|
||||
registry.push_alias(ProfileAlias {
|
||||
source,
|
||||
name,
|
||||
target_source: target_source.or(Some(source)),
|
||||
target_name,
|
||||
});
|
||||
}
|
||||
|
||||
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 discover_profile_dir(
|
||||
registry: &mut ProfileRegistry,
|
||||
source: ProfileRegistrySource,
|
||||
dir: &Path,
|
||||
) -> Result<(), ProfileError> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).map_err(|source| ProfileError::ConfigRead {
|
||||
path: dir.to_path_buf(),
|
||||
source,
|
||||
})? {
|
||||
let entry = entry.map_err(|source| ProfileError::ConfigRead {
|
||||
path: dir.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("nix") {
|
||||
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
registry.push_entry(ProfileRegistryEntry {
|
||||
source,
|
||||
name: name.to_string(),
|
||||
path,
|
||||
description: None,
|
||||
is_default: false,
|
||||
});
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
let profile = path.join("profile.nix");
|
||||
if profile.is_file() {
|
||||
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
|
||||
registry.push_entry(ProfileRegistryEntry {
|
||||
source,
|
||||
name: name.to_string(),
|
||||
path: profile,
|
||||
description: None,
|
||||
is_default: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
|
||||
if let Some((prefix, name)) = raw.split_once(':') {
|
||||
if let Some(source) = ProfileRegistrySource::parse(prefix) {
|
||||
return (Some(source), name.to_string());
|
||||
}
|
||||
}
|
||||
(None, raw.to_string())
|
||||
}
|
||||
|
||||
fn extract_manifest_value(raw: &serde_json::Value) -> Result<serde_json::Value, ProfileError> {
|
||||
match raw {
|
||||
serde_json::Value::Object(map) => {
|
||||
@@ -241,7 +688,15 @@ fn absolutize(path: &Path) -> Result<PathBuf, ProfileError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised while evaluating and validating a profile.
|
||||
fn join_if_relative(base: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
base.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised while evaluating, discovering, and validating a profile.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProfileError {
|
||||
#[error("invalid profile path {}: {message}", .path.display())]
|
||||
@@ -271,6 +726,29 @@ pub enum ProfileError {
|
||||
source: serde_json::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 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("failed to decode profile artifact envelope: {source}")]
|
||||
ArtifactShape {
|
||||
#[source]
|
||||
@@ -300,6 +778,7 @@ pub enum ProfileError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AuthRef, Permission, SchemeKind};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn artifact() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
@@ -425,4 +904,199 @@ mod tests {
|
||||
assert!(err.to_string().contains("requires the `nix` command"));
|
||||
assert!(err.to_string().contains("--manifest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_preserves_paths_and_source_qualified_names() {
|
||||
assert!(matches!(
|
||||
ProfileSelector::parse_cli("./coder.nix"),
|
||||
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 discovery_reads_user_and_project_registry_and_project_default_wins() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_manifest = tmp.path().join("user.toml");
|
||||
let project_dir = tmp.path().join("project/.insomnia");
|
||||
std::fs::create_dir_all(&project_dir).unwrap();
|
||||
let project_manifest = project_dir.join("manifest.toml");
|
||||
std::fs::write(
|
||||
&user_manifest,
|
||||
r#"
|
||||
[profiles]
|
||||
default = "coder"
|
||||
[profiles.profile]
|
||||
coder = "profiles/user-coder.nix"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&project_manifest,
|
||||
r#"
|
||||
[profiles]
|
||||
default = "project:coder"
|
||||
[profiles.profile.coder]
|
||||
path = "profiles/project-coder.nix"
|
||||
description = "Project coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry =
|
||||
ProfileDiscovery::with_sources(None, Some(user_manifest), Some(project_manifest))
|
||||
.discover()
|
||||
.unwrap();
|
||||
|
||||
let default = registry.default_entry().unwrap();
|
||||
assert_eq!(default.source, ProfileRegistrySource::Project);
|
||||
assert_eq!(default.name, "coder");
|
||||
assert!(default.path.ends_with("profiles/project-coder.nix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_alias_unqualified_target_resolves_within_declaring_source() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_manifest = tmp.path().join("user.toml");
|
||||
let project_dir = tmp.path().join("project/.insomnia");
|
||||
std::fs::create_dir_all(&project_dir).unwrap();
|
||||
let project_manifest = project_dir.join("manifest.toml");
|
||||
std::fs::write(
|
||||
&user_manifest,
|
||||
r#"
|
||||
[profiles.profile]
|
||||
coder = "profiles/user-coder.nix"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&project_manifest,
|
||||
r#"
|
||||
[profiles.profile]
|
||||
coder = "profiles/project-coder.nix"
|
||||
[profiles.alias]
|
||||
default-coder = "coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry =
|
||||
ProfileDiscovery::with_sources(None, Some(user_manifest), Some(project_manifest))
|
||||
.discover()
|
||||
.unwrap();
|
||||
let selected = registry
|
||||
.select(&ProfileSelector::source_named(
|
||||
ProfileRegistrySource::Project,
|
||||
"default-coder",
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(selected.source, ProfileRegistrySource::Project);
|
||||
assert_eq!(selected.name, "coder");
|
||||
assert!(selected.path.ends_with("profiles/project-coder.nix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_default_alias_marks_resolved_default_entry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project_dir = tmp.path().join("project/.insomnia");
|
||||
std::fs::create_dir_all(&project_dir).unwrap();
|
||||
let project_manifest = project_dir.join("manifest.toml");
|
||||
std::fs::write(
|
||||
&project_manifest,
|
||||
r#"
|
||||
[profiles]
|
||||
default = "work"
|
||||
[profiles.profile]
|
||||
coder = "profiles/coder.nix"
|
||||
[profiles.alias]
|
||||
work = "coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = ProfileDiscovery::with_sources(None, None, Some(project_manifest))
|
||||
.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 {
|
||||
source: ProfileRegistrySource::User,
|
||||
name: "coder".to_string(),
|
||||
path: PathBuf::from("/user/coder.nix"),
|
||||
description: None,
|
||||
is_default: false,
|
||||
});
|
||||
registry.push_entry(ProfileRegistryEntry {
|
||||
source: ProfileRegistrySource::Project,
|
||||
name: "coder".to_string(),
|
||||
path: PathBuf::from("/project/coder.nix"),
|
||||
description: None,
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
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, PathBuf::from("/project/coder.nix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_resolve_within_their_source() {
|
||||
let mut registry = ProfileRegistry::default();
|
||||
registry.push_entry(ProfileRegistryEntry {
|
||||
source: ProfileRegistrySource::Project,
|
||||
name: "coder".to_string(),
|
||||
path: PathBuf::from("/project/coder.nix"),
|
||||
description: None,
|
||||
is_default: false,
|
||||
});
|
||||
registry.push_alias(ProfileAlias {
|
||||
source: ProfileRegistrySource::Project,
|
||||
name: "default-coder".to_string(),
|
||||
target_source: Some(ProfileRegistrySource::Project),
|
||||
target_name: "coder".to_string(),
|
||||
});
|
||||
|
||||
let selected = registry
|
||||
.select(&ProfileSelector::source_named(
|
||||
ProfileRegistrySource::Project,
|
||||
"default-coder",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(selected.name, "coder");
|
||||
}
|
||||
}
|
||||
|
||||
+57
-13
@@ -13,14 +13,15 @@ use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
|
||||
about = "Spawn a Pod process from manifest layers or a single manifest file"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Nix profile to evaluate with `nix eval --json --file <PATH>`.
|
||||
/// Profiles are resolved artifacts, not manifest-cascade layers.
|
||||
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
|
||||
/// discovered profile name, `default`, or a source-qualified name such as
|
||||
/// `project:coder`.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "PATH",
|
||||
value_name = "PROFILE",
|
||||
conflicts_with_all = ["manifest", "project", "overlay", "pod", "session", "adopt"]
|
||||
)]
|
||||
profile: Option<PathBuf>,
|
||||
profile: Option<String>,
|
||||
|
||||
/// Pod name override for a freshly-created profile Pod. This does not use
|
||||
/// `--pod` restore semantics, so it must not attach/restore existing Pod
|
||||
@@ -97,10 +98,11 @@ fn resolve_manifest_with_user_manifest_env_and_profile_loader<F>(
|
||||
load_profile_fn: F,
|
||||
) -> Result<(PodManifest, PromptLoader), String>
|
||||
where
|
||||
F: FnOnce(&Path, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
|
||||
F: FnOnce(&ProfileSelector, Option<&str>) -> Result<(PodManifest, PromptLoader), String>,
|
||||
{
|
||||
if let Some(path) = &cli.profile {
|
||||
return load_profile_fn(path, cli.profile_pod_name.as_deref());
|
||||
if let Some(profile) = &cli.profile {
|
||||
let selector = ProfileSelector::parse_cli(profile);
|
||||
return load_profile_fn(&selector, cli.profile_pod_name.as_deref());
|
||||
}
|
||||
|
||||
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
|
||||
@@ -122,13 +124,16 @@ where
|
||||
}
|
||||
|
||||
fn load_profile(
|
||||
path: &Path,
|
||||
selector: &ProfileSelector,
|
||||
pod_name_override: Option<&str>,
|
||||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let resolver = NixProfileResolver::new();
|
||||
let mut resolved = resolver
|
||||
.resolve(&ProfileSelector::path(path.to_path_buf()))
|
||||
.map_err(|e| format!("failed to resolve profile {}: {e}", path.display()))?;
|
||||
let mut resolved = resolver.resolve(selector).map_err(|e| {
|
||||
format!(
|
||||
"failed to resolve profile {}: {e}",
|
||||
selector.display_label()
|
||||
)
|
||||
})?;
|
||||
if let Some(pod_name) = pod_name_override {
|
||||
resolved.manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
@@ -468,9 +473,9 @@ permission = "write"
|
||||
let (manifest, loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
|
||||
&cli,
|
||||
Some(OsString::from("non-existent-user-manifest.toml")),
|
||||
|path, pod_name| {
|
||||
|selector, pod_name| {
|
||||
called = true;
|
||||
assert_eq!(path, profile.as_path());
|
||||
assert_eq!(selector, &ProfileSelector::path(profile.clone()));
|
||||
assert_eq!(pod_name, Some("from-profile-name"));
|
||||
let mut manifest =
|
||||
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
|
||||
@@ -488,6 +493,45 @@ permission = "write"
|
||||
assert!(loader.workspace_dir().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_accepts_source_qualified_discovered_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--profile",
|
||||
"project:coder",
|
||||
"--profile-pod-name",
|
||||
"from-profile-name",
|
||||
])
|
||||
.unwrap();
|
||||
let mut called = false;
|
||||
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
|
||||
&cli,
|
||||
None,
|
||||
|selector, pod_name| {
|
||||
called = true;
|
||||
assert_eq!(
|
||||
selector,
|
||||
&ProfileSelector::source_named(
|
||||
manifest::ProfileRegistrySource::Project,
|
||||
"coder"
|
||||
)
|
||||
);
|
||||
let mut manifest =
|
||||
PodManifest::from_toml(&manifest_toml("from-profile", tmp.path())).unwrap();
|
||||
if let Some(pod_name) = pod_name {
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(called);
|
||||
assert_eq!(manifest.pod.name, "from-profile-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_allows_empty_user_manifest_env() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+11
-11
@@ -61,7 +61,7 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||
#[derive(Debug)]
|
||||
enum Mode {
|
||||
Spawn {
|
||||
profile_path: Option<PathBuf>,
|
||||
profile: Option<String>,
|
||||
},
|
||||
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
|
||||
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
|
||||
@@ -113,7 +113,7 @@ where
|
||||
let mut multi = false;
|
||||
let mut session: Option<SegmentId> = None;
|
||||
let mut pod: Option<String> = None;
|
||||
let mut profile_path: Option<PathBuf> = None;
|
||||
let mut profile: Option<String> = None;
|
||||
let mut socket_override: Option<PathBuf> = None;
|
||||
let mut socket_seen = false;
|
||||
let mut positional: Option<String> = None;
|
||||
@@ -148,7 +148,7 @@ where
|
||||
let raw = args
|
||||
.get(i + 1)
|
||||
.ok_or(ParseError::MissingValue("--profile"))?;
|
||||
profile_path = Some(PathBuf::from(raw));
|
||||
profile = Some(raw.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--socket" => {
|
||||
@@ -197,7 +197,7 @@ where
|
||||
"--multi and --socket are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if profile_path.is_some() {
|
||||
if profile.is_some() {
|
||||
return Err(ParseError::Conflict(
|
||||
"--multi and --profile are mutually exclusive",
|
||||
));
|
||||
@@ -220,7 +220,7 @@ where
|
||||
"--pod and --resume are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if profile_path.is_some()
|
||||
if profile.is_some()
|
||||
&& (resume || session.is_some() || pod.is_some() || positional.is_some() || socket_seen)
|
||||
{
|
||||
return Err(ParseError::Conflict(
|
||||
@@ -246,7 +246,7 @@ where
|
||||
socket_override,
|
||||
});
|
||||
}
|
||||
Ok(Mode::Spawn { profile_path })
|
||||
Ok(Mode::Spawn { profile })
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -270,7 +270,7 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
let result = match mode {
|
||||
Mode::Spawn { profile_path } => run_spawn(None, profile_path).await,
|
||||
Mode::Spawn { profile } => run_spawn(None, profile).await,
|
||||
Mode::PodName {
|
||||
pod_name,
|
||||
socket_override,
|
||||
@@ -473,9 +473,9 @@ fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) ->
|
||||
|
||||
async fn run_spawn(
|
||||
resume_from: Option<SegmentId>,
|
||||
profile_path: Option<PathBuf>,
|
||||
profile: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run(resume_from, profile_path).await? {
|
||||
let ready = match spawn::run(resume_from, profile).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
@@ -1182,8 +1182,8 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_profile_spawn_mode() {
|
||||
match parse_args_from(["--profile", "/profiles/coder.nix"]).unwrap() {
|
||||
Mode::Spawn { profile_path } => {
|
||||
assert_eq!(profile_path, Some(PathBuf::from("/profiles/coder.nix")));
|
||||
Mode::Spawn { profile } => {
|
||||
assert_eq!(profile, Some("/profiles/coder.nix".to_string()));
|
||||
}
|
||||
_ => panic!("expected Spawn mode"),
|
||||
}
|
||||
|
||||
+265
-34
@@ -14,14 +14,14 @@
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{SpawnConfig, spawn_pod};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use manifest::{
|
||||
PodManifestConfig, ScopeConfig, find_project_manifest_from, load_layer, user_manifest_path,
|
||||
user_manifest_path_from_env,
|
||||
PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
|
||||
user_manifest_path, user_manifest_path_from_env,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
@@ -93,18 +93,20 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||
/// passes `--session <id>` to the spawned `insomnia-pod` child.
|
||||
pub async fn run(
|
||||
resume_from: Option<SegmentId>,
|
||||
profile_path: Option<PathBuf>,
|
||||
profile: Option<String>,
|
||||
) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
let scope_origin = match profile_path.as_ref() {
|
||||
Some(path) => ScopeOrigin::FromProfile(path.clone()),
|
||||
None => defaults.scope_origin,
|
||||
};
|
||||
let mut profile_choices = defaults.profile_choices;
|
||||
let profile_index = initial_profile_index(
|
||||
&mut profile_choices,
|
||||
profile.as_deref(),
|
||||
defaults.default_profile_index,
|
||||
);
|
||||
|
||||
let mut form = Form {
|
||||
cwd: defaults.cwd.clone(),
|
||||
cascade_has_scope: defaults.cascade_has_scope,
|
||||
scope_origin,
|
||||
scope_origin: defaults.scope_origin,
|
||||
name_cursor: defaults.default_name.chars().count(),
|
||||
name: defaults.default_name,
|
||||
message: None,
|
||||
@@ -112,7 +114,8 @@ pub async fn run(
|
||||
resume_from,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_path,
|
||||
profile_choices,
|
||||
profile_index,
|
||||
};
|
||||
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
@@ -143,6 +146,8 @@ pub async fn run(
|
||||
Some(Action::Right) => form.move_right(),
|
||||
Some(Action::Home) => form.name_cursor = 0,
|
||||
Some(Action::End) => form.name_cursor = form.name.chars().count(),
|
||||
Some(Action::ProfileNext) => form.cycle_profile_next(),
|
||||
Some(Action::ProfilePrev) => form.cycle_profile_prev(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +217,15 @@ struct SpawnDefaults {
|
||||
cascade_has_scope: bool,
|
||||
scope_origin: ScopeOrigin,
|
||||
default_name: String,
|
||||
default_profile_index: usize,
|
||||
profile_choices: Vec<ProfileChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProfileChoice {
|
||||
selector: Option<String>,
|
||||
label: String,
|
||||
is_default: bool,
|
||||
}
|
||||
|
||||
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
@@ -260,14 +274,74 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "pod".to_string());
|
||||
|
||||
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
|
||||
|
||||
Ok(SpawnDefaults {
|
||||
cwd,
|
||||
cascade_has_scope,
|
||||
scope_origin,
|
||||
default_name,
|
||||
default_profile_index,
|
||||
profile_choices,
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
|
||||
let mut choices = vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}];
|
||||
|
||||
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
|
||||
return (choices, 0);
|
||||
};
|
||||
|
||||
for entry in registry.entries() {
|
||||
let mut label = entry.qualified_name();
|
||||
if entry.is_default {
|
||||
label.push_str(" (default)");
|
||||
}
|
||||
if let Some(description) = entry.description.as_deref() {
|
||||
label.push_str(" — ");
|
||||
label.push_str(description);
|
||||
}
|
||||
choices.push(ProfileChoice {
|
||||
selector: Some(entry.qualified_name()),
|
||||
label,
|
||||
is_default: entry.is_default,
|
||||
});
|
||||
}
|
||||
|
||||
let default_index = choices
|
||||
.iter()
|
||||
.position(|choice| choice.is_default)
|
||||
.unwrap_or(0);
|
||||
(choices, default_index)
|
||||
}
|
||||
|
||||
fn initial_profile_index(
|
||||
choices: &mut Vec<ProfileChoice>,
|
||||
explicit_profile: Option<&str>,
|
||||
default_index: usize,
|
||||
) -> usize {
|
||||
let Some(selector) = explicit_profile else {
|
||||
return default_index.min(choices.len().saturating_sub(1));
|
||||
};
|
||||
if let Some(index) = choices
|
||||
.iter()
|
||||
.position(|choice| choice.selector.as_deref() == Some(selector))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
choices.push(ProfileChoice {
|
||||
selector: Some(selector.to_string()),
|
||||
label: selector.to_string(),
|
||||
is_default: false,
|
||||
});
|
||||
choices.len() - 1
|
||||
}
|
||||
|
||||
fn user_manifest_path_for_spawn(
|
||||
env_value: Option<OsString>,
|
||||
default_user_manifest: Option<PathBuf>,
|
||||
@@ -287,7 +361,12 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
||||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
resume_scope: None,
|
||||
profile_path: None,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +390,8 @@ enum Action {
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
ProfileNext,
|
||||
ProfilePrev,
|
||||
}
|
||||
|
||||
fn poll_event() -> io::Result<Option<Action>> {
|
||||
@@ -331,6 +412,8 @@ fn poll_event() -> io::Result<Option<Action>> {
|
||||
KeyCode::Delete => Some(Action::Delete),
|
||||
KeyCode::Left => Some(Action::Left),
|
||||
KeyCode::Right => Some(Action::Right),
|
||||
KeyCode::Up | KeyCode::BackTab => Some(Action::ProfilePrev),
|
||||
KeyCode::Down | KeyCode::Tab => Some(Action::ProfileNext),
|
||||
KeyCode::Home => Some(Action::Home),
|
||||
KeyCode::End => Some(Action::End),
|
||||
KeyCode::Char(c) if !ctrl && is_safe_name_char(c) => Some(Action::Char(c)),
|
||||
@@ -361,7 +444,7 @@ async fn wait_for_ready(
|
||||
|
||||
let config = SpawnConfig {
|
||||
pod_name: form.name.clone(),
|
||||
profile_path: form.profile_path.clone(),
|
||||
profile: form.selected_profile_selector(),
|
||||
overlay_toml: overlay_toml.to_string(),
|
||||
cwd,
|
||||
resume_from: form.resume_from,
|
||||
@@ -438,7 +521,6 @@ enum ScopeOrigin {
|
||||
FromUser,
|
||||
FromProject,
|
||||
CwdDefault,
|
||||
FromProfile(PathBuf),
|
||||
}
|
||||
|
||||
struct Form {
|
||||
@@ -473,10 +555,11 @@ struct Form {
|
||||
/// resume runs, and serialized into the overlay instead of cwd-default
|
||||
/// scope so resume does not silently broaden access.
|
||||
resume_scope: Option<ScopeConfig>,
|
||||
/// Optional Nix profile passed to `insomnia-pod --profile` for fresh spawns.
|
||||
/// This is not used for resume/attach flows because those must restore Pod
|
||||
/// state rather than re-evaluate a profile source.
|
||||
profile_path: Option<PathBuf>,
|
||||
/// Optional Nix profile choices passed to `insomnia-pod --profile` for
|
||||
/// fresh spawns. This is not used for resume/attach flows because those must
|
||||
/// restore Pod state rather than re-evaluate a profile source.
|
||||
profile_choices: Vec<ProfileChoice>,
|
||||
profile_index: usize,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
@@ -519,6 +602,37 @@ impl Form {
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_profile(&self) -> Option<&ProfileChoice> {
|
||||
self.profile_choices
|
||||
.get(self.profile_index)
|
||||
.filter(|choice| choice.selector.is_some())
|
||||
}
|
||||
|
||||
fn selected_profile_selector(&self) -> Option<String> {
|
||||
self.selected_profile()
|
||||
.and_then(|choice| choice.selector.clone())
|
||||
}
|
||||
|
||||
fn cycle_profile_next(&mut self) {
|
||||
if self.profile_choices.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.profile_index = (self.profile_index + 1) % self.profile_choices.len();
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
fn cycle_profile_prev(&mut self) {
|
||||
if self.profile_choices.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.profile_index = if self.profile_index == 0 {
|
||||
self.profile_choices.len() - 1
|
||||
} else {
|
||||
self.profile_index - 1
|
||||
};
|
||||
self.message = None;
|
||||
}
|
||||
|
||||
fn char_offset_to_byte(&self, char_off: usize) -> usize {
|
||||
self.name
|
||||
.char_indices()
|
||||
@@ -588,6 +702,18 @@ fn name_line(form: &Form) -> Line<'_> {
|
||||
}
|
||||
|
||||
fn context_line(form: &Form) -> Line<'_> {
|
||||
if let Some(profile) = form.profile_choices.get(form.profile_index) {
|
||||
return Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(profile.label.as_str(), Style::default().fg(Color::Green)),
|
||||
Span::styled(
|
||||
" (tab/down to change)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
match form.scope_origin {
|
||||
ScopeOrigin::FromProject => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
@@ -608,26 +734,14 @@ fn context_line(form: &Form) -> Line<'_> {
|
||||
),
|
||||
Span::styled(" (write, default)", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
ScopeOrigin::FromProfile(ref path) => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
path.display().to_string(),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::styled(" (resolved by pod)", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
fn hint_line() -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||
Span::raw(" spawn "),
|
||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" cancel"),
|
||||
])
|
||||
Line::from(vec![Span::styled(
|
||||
" enter spawn · tab/down next profile · shift-tab/up prev · esc cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)])
|
||||
}
|
||||
|
||||
fn message_line(form: &Form) -> Line<'_> {
|
||||
@@ -663,7 +777,12 @@ mod tests {
|
||||
resume_from: None,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_path: None,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +793,12 @@ mod tests {
|
||||
cascade_has_scope: true,
|
||||
scope_origin: ScopeOrigin::FromProject,
|
||||
default_name: "ignored".to_string(),
|
||||
default_profile_index: 0,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
};
|
||||
let f = form_for_pod_name("agent".to_string(), defaults);
|
||||
|
||||
@@ -774,6 +899,112 @@ permission = "write"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_use_project_registry_default_alias() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let insomnia = project.join(".insomnia");
|
||||
std::fs::create_dir_all(&insomnia).unwrap();
|
||||
std::fs::write(
|
||||
insomnia.join("manifest.toml"),
|
||||
r#"
|
||||
[profiles]
|
||||
default = "work"
|
||||
[profiles.profile]
|
||||
coder = "profiles/coder.nix"
|
||||
[profiles.alias]
|
||||
work = "coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
assert_eq!(default_index, 1);
|
||||
let selected = &choices[default_index];
|
||||
assert_eq!(selected.selector.as_deref(), Some("project:coder"));
|
||||
assert_eq!(selected.label, "project:coder (default)");
|
||||
assert!(selected.is_default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_include_no_profile_source_labels_and_default_marker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let insomnia = project.join(".insomnia");
|
||||
std::fs::create_dir_all(&insomnia).unwrap();
|
||||
std::fs::write(
|
||||
insomnia.join("manifest.toml"),
|
||||
r#"
|
||||
[profiles]
|
||||
default = "coder"
|
||||
[profiles.profile.coder]
|
||||
path = "profiles/coder.nix"
|
||||
description = "Project coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
assert_eq!(choices[0].selector, None);
|
||||
assert_eq!(choices[0].label, "manifest cascade");
|
||||
assert_eq!(default_index, 1);
|
||||
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
|
||||
assert_eq!(choices[1].label, "project:coder (default) — Project coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cycle_selects_profiles_and_can_opt_out_of_default() {
|
||||
let mut form = form("coder", true);
|
||||
form.profile_choices = vec![
|
||||
ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
},
|
||||
ProfileChoice {
|
||||
selector: Some("project:coder".to_string()),
|
||||
label: "project:coder (default)".to_string(),
|
||||
is_default: true,
|
||||
},
|
||||
ProfileChoice {
|
||||
selector: Some("user:reviewer".to_string()),
|
||||
label: "user:reviewer".to_string(),
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
form.profile_index = 1;
|
||||
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("project:coder")
|
||||
);
|
||||
form.cycle_profile_next();
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("user:reviewer")
|
||||
);
|
||||
form.cycle_profile_next();
|
||||
assert_eq!(form.selected_profile_selector(), None);
|
||||
form.cycle_profile_prev();
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("user:reviewer")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
|
||||
let mut choices = vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}];
|
||||
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
|
||||
assert_eq!(selected, 1);
|
||||
assert_eq!(choices[1].selector.as_deref(), Some("coder"));
|
||||
assert_eq!(choices[1].label, "coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_input_handles_insert_backspace_and_cursor() {
|
||||
let mut f = form("", false);
|
||||
|
||||
Reference in New Issue
Block a user