use crate::config::resolve_config_dir;
use crate::fs_utils::{atomic_write_new_with_mode, atomic_write_with_mode};
use crate::schema_utils;
use crate::sdd::project::config::{SddConfig, llmanspec_schema};
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::tool::config as tool_config;
use anyhow::{Result, anyhow};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
pub const SCHEMA_OUTPUT_DIR: &str = "artifacts/schema/configs/en";
pub const GLOBAL_SCHEMA_FILE: &str = "llman-config.schema.json";
pub const PROJECT_SCHEMA_FILE: &str = "llman-project-config.schema.json";
pub const LLMANSPEC_SCHEMA_FILE: &str = "llmanspec-config.schema.json";
pub const GLOBAL_SCHEMA_URL: &str = "https://raw.githubusercontent.com/StrayDragon/llman/main/artifacts/schema/configs/en/llman-config.schema.json";
pub const PROJECT_SCHEMA_URL: &str = "https://raw.githubusercontent.com/StrayDragon/llman/main/artifacts/schema/configs/en/llman-project-config.schema.json";
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(
title = "llman Global Config",
description = "Global configuration for llman."
)]
pub struct GlobalConfig {
#[schemars(description = "Configuration version for tool settings.")]
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(description = "Global-only settings for llman.")]
pub skills: Option<GlobalSkillsConfig>,
#[schemars(description = "Tool configuration.")]
pub tools: tool_config::ToolsSection,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(
title = "llman Project Config",
description = "Project-level configuration for llman. This is a subset of the global config."
)]
pub struct ProjectConfig {
#[schemars(description = "Configuration version for tool settings.")]
pub version: String,
#[schemars(description = "Tool configuration.")]
pub tools: tool_config::ToolsSection,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
#[schemars(
title = "llman Skills Config",
description = "Global skills configuration. Sources are declared only via skills.repo[].",
deny_unknown_fields
)]
pub struct GlobalSkillsConfig {
#[schemars(
description = "Skills repository sources. Each entry declares a local path (and optional display name). Missing or non-directory paths are skipped with a warning at startup."
)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repo: Vec<GlobalSkillsRepoEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(
title = "llman Skills Repo Source",
description = "A single skills repository source under skills.repo[]."
)]
pub struct GlobalSkillsRepoEntry {
#[schemars(
description = "Optional human-readable label used in the TUI. When omitted, the positional index is used as the stable id."
)]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[schemars(
description = "Local filesystem path to the skills repository root. Supports ~ and env vars."
)]
pub path: String,
}
impl Default for GlobalConfig {
fn default() -> Self {
let tool_defaults = tool_config::ToolConfig::default();
Self {
version: tool_defaults.version,
tools: tool_defaults.tools,
skills: Some(GlobalSkillsConfig {
repo: vec![GlobalSkillsRepoEntry {
name: Some("default".to_string()),
path: "$LLMAN_CONFIG_DIR/skills".to_string(),
}],
}),
}
}
}
impl Default for ProjectConfig {
fn default() -> Self {
let tool_defaults = tool_config::ToolConfig::default();
Self {
version: tool_defaults.version,
tools: tool_defaults.tools,
}
}
}
pub struct SchemaPaths {
pub root: PathBuf,
pub global: PathBuf,
pub project: PathBuf,
pub llmanspec: PathBuf,
}
pub struct SchemaArtifacts {
pub global: String,
pub project: String,
pub llmanspec: String,
}
#[derive(Debug, Clone, Copy)]
pub enum ConfigSchemaKind {
Global,
Project,
Llmanspec,
}
pub enum ApplyResult {
Updated,
Unchanged,
Missing,
}
pub fn schema_paths() -> SchemaPaths {
let root = PathBuf::from(SCHEMA_OUTPUT_DIR);
SchemaPaths {
global: root.join(GLOBAL_SCHEMA_FILE),
project: root.join(PROJECT_SCHEMA_FILE),
llmanspec: root.join(LLMANSPEC_SCHEMA_FILE),
root,
}
}
pub fn apply_schema_header(path: &Path, schema_url: &str) -> Result<ApplyResult> {
if !path.exists() {
return Ok(ApplyResult::Missing);
}
let content = fs::read_to_string(path).map_err(|e| {
anyhow!(t!(
"self.schema.read_failed",
path = path.display(),
error = e
))
})?;
let (updated, changed) = schema_utils::apply_schema_header_to_content(&content, schema_url);
if !changed {
return Ok(ApplyResult::Unchanged);
}
atomic_write_with_mode(path, updated.as_bytes(), None).map_err(|e| {
anyhow!(t!(
"self.schema.write_failed",
path = path.display(),
error = e
))
})?;
Ok(ApplyResult::Updated)
}
pub fn generate_schema_artifacts() -> Result<SchemaArtifacts> {
let global = schema_utils::generate_schema::<GlobalConfig>();
let project = schema_utils::generate_schema::<ProjectConfig>();
let llmanspec = llmanspec_schema();
Ok(SchemaArtifacts {
global: serde_json::to_string_pretty(&global)
.map_err(|e| anyhow!(t!("self.schema.generate_failed", error = e)))?,
project: serde_json::to_string_pretty(&project)
.map_err(|e| anyhow!(t!("self.schema.generate_failed", error = e)))?,
llmanspec: serde_json::to_string_pretty(&llmanspec)
.map_err(|e| anyhow!(t!("self.schema.generate_failed", error = e)))?,
})
}
pub fn validate_yaml_value(
kind: ConfigSchemaKind,
value: &serde_json::Value,
) -> Result<(), String> {
match kind {
ConfigSchemaKind::Global => {
schema_utils::validate_yaml_value_against::<GlobalConfig>(value)
}
ConfigSchemaKind::Project => {
schema_utils::validate_yaml_value_against::<ProjectConfig>(value)
}
ConfigSchemaKind::Llmanspec => {
schema_utils::validate_yaml_value_against::<SddConfig>(value)
}
}
}
pub fn write_schema_files() -> Result<SchemaPaths> {
let artifacts = generate_schema_artifacts()?;
let paths = schema_paths();
fs::create_dir_all(&paths.root).map_err(|e| {
anyhow!(t!(
"self.schema.write_failed",
path = paths.root.display(),
error = e
))
})?;
atomic_write_with_mode(&paths.global, artifacts.global.as_bytes(), None).map_err(|e| {
anyhow!(t!(
"self.schema.write_failed",
path = paths.global.display(),
error = e
))
})?;
atomic_write_with_mode(&paths.project, artifacts.project.as_bytes(), None).map_err(|e| {
anyhow!(t!(
"self.schema.write_failed",
path = paths.project.display(),
error = e
))
})?;
atomic_write_with_mode(&paths.llmanspec, artifacts.llmanspec.as_bytes(), None).map_err(
|e| {
anyhow!(t!(
"self.schema.write_failed",
path = paths.llmanspec.display(),
error = e
))
},
)?;
Ok(paths)
}
pub fn ensure_global_sample_config(config_dir: &Path) -> Result<Option<PathBuf>> {
let path = config_dir.join("config.yaml");
if path.exists() {
return Ok(None);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
anyhow!(t!(
"self.schema.write_failed",
path = parent.display(),
error = e
))
})?;
}
let config = GlobalConfig::default();
let yaml = serde_saphyr::to_string(&config)
.map_err(|e| anyhow!(t!("self.schema.generate_failed", error = e)))?;
let content = schema_utils::prepend_schema_header(&yaml, GLOBAL_SCHEMA_URL);
let created = atomic_write_new_with_mode(&path, content.as_bytes(), None).map_err(|e| {
anyhow!(t!(
"self.schema.write_failed",
path = path.display(),
error = e
))
})?;
if created { Ok(Some(path)) } else { Ok(None) }
}
pub fn global_config_path() -> Result<PathBuf> {
Ok(resolve_config_dir(None)?.join("config.yaml"))
}
fn find_config_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if is_config_root(¤t) {
return Some(current);
}
if !current.pop() {
break;
}
}
None
}
fn is_config_root(path: &Path) -> bool {
has_root_marker(path, ".git")
|| has_root_marker(path, ".llman")
|| has_root_marker(path, LLMANSPEC_DIR_NAME)
}
fn has_root_marker(root: &Path, name: &str) -> bool {
let candidate = root.join(name);
fs::symlink_metadata(&candidate)
.map(|meta| meta.is_dir() || meta.is_file())
.unwrap_or(false)
}
pub fn project_config_path() -> Result<PathBuf> {
let cwd = env::current_dir()?;
Ok(project_config_path_from(&cwd))
}
pub fn llmanspec_config_path() -> Result<PathBuf> {
let cwd = env::current_dir()?;
Ok(llmanspec_config_path_from(&cwd))
}
fn project_config_path_from(cwd: &Path) -> PathBuf {
let root = find_config_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
root.join(".llman").join("config.yaml")
}
fn llmanspec_config_path_from(cwd: &Path) -> PathBuf {
let root = find_config_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
root.join(LLMANSPEC_DIR_NAME).join("config.yaml")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn project_and_llmanspec_paths_discover_root_from_subdir() {
let temp = TempDir::new().expect("temp dir");
let root = temp.path().join("repo");
let nested = root.join("a").join("b");
fs::create_dir_all(&nested).expect("create nested");
fs::create_dir_all(root.join(".git")).expect("create git dir");
assert_eq!(
project_config_path_from(&nested),
root.join(".llman").join("config.yaml")
);
assert_eq!(
llmanspec_config_path_from(&nested),
root.join(LLMANSPEC_DIR_NAME).join("config.yaml")
);
}
#[test]
fn skills_config_schema_round_trips_repo() {
let multi = GlobalSkillsConfig {
repo: vec![
GlobalSkillsRepoEntry {
name: Some("Team".to_string()),
path: "/team/skills".to_string(),
},
GlobalSkillsRepoEntry {
name: None,
path: "/personal/skills".to_string(),
},
],
};
let yaml = serde_saphyr::to_string(&multi).expect("serialize");
let back: GlobalSkillsConfig = serde_saphyr::from_str(&yaml).expect("deserialize");
assert_eq!(back, multi);
assert_eq!(back.repo.len(), 2);
}
#[test]
fn skills_config_rejects_legacy_dir_field() {
let err = serde_saphyr::from_str::<GlobalSkillsConfig>("dir: /old/skills\n")
.expect_err("legacy dir must be rejected");
assert!(
err.to_string().contains("dir") || err.to_string().contains("unknown"),
"unexpected error: {err}"
);
}
#[test]
fn default_global_config_emits_default_repo() {
let default = GlobalConfig::default();
let skills = default.skills.expect("default skills section");
assert_eq!(skills.repo.len(), 1);
assert_eq!(skills.repo[0].name.as_deref(), Some("default"));
assert_eq!(skills.repo[0].path, "$LLMAN_CONFIG_DIR/skills");
}
}