use anyhow::{Context, Result, anyhow};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::gateway::kumiho_client::{KumihoClient, slugify};
use crate::skills::{SkillContentMigration, SkillManifest, migrate_skill_toml_to_content_file};
pub const SKILL_ITEM_KIND: &str = "skilldef";
pub const PUBLISHED_TAG: &str = "published";
pub const PREVIOUS_PUBLISHED_TAG: &str = "previous_published";
pub const SKILL_ARTIFACT_NAME: &str = "skill";
pub fn build_published_kref(memory_project: &str, slug: &str) -> String {
format!("kref://{memory_project}/Skills/{slug}.{SKILL_ITEM_KIND}?t={PUBLISHED_TAG}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillRegistration {
AlreadyRegistered { kref: String },
Registered {
kref: String,
item_kref: String,
revision_kref: String,
},
}
impl SkillRegistration {
pub fn kref(&self) -> &str {
match self {
Self::AlreadyRegistered { kref } => kref,
Self::Registered { kref, .. } => kref,
}
}
}
pub async fn register_skill_with_kumiho(
skill_dir: &Path,
client: &KumihoClient,
memory_project: &str,
) -> Result<SkillRegistration> {
let manifest_path = skill_dir.join("SKILL.toml");
if !manifest_path.exists() {
return Err(anyhow!("no SKILL.toml at {}", manifest_path.display()));
}
let manifest = read_manifest(&manifest_path)?;
if let Some(kref) = manifest.skill.kref.as_ref() {
return Ok(SkillRegistration::AlreadyRegistered { kref: kref.clone() });
}
if manifest.skill.content_file.is_none() && !manifest.prompts.is_empty() {
let result = migrate_skill_toml_to_content_file(&manifest_path)
.context("auto-migrating legacy embedded prompts to content file")?;
if !matches!(result, SkillContentMigration::Migrated { .. }) {
return Err(anyhow!(
"unexpected migrate_skill_toml_to_content_file result: {result:?}"
));
}
}
let manifest = read_manifest(&manifest_path)?;
let slug = slugify(&manifest.skill.name);
if slug.is_empty() {
return Err(anyhow!(
"skill name {:?} slugified to empty; cannot register",
manifest.skill.name
));
}
let content_path = resolve_content_file(skill_dir, &manifest)?;
let content_uri = format_file_uri(&content_path);
client
.ensure_project(memory_project)
.await
.with_context(|| format!("ensure_project({memory_project})"))?;
client
.ensure_space(memory_project, "Skills")
.await
.with_context(|| format!("ensure_space({memory_project}/Skills)"))?;
let space_path = format!("/{memory_project}/Skills");
let item = client
.create_item(&space_path, &slug, SKILL_ITEM_KIND, HashMap::new())
.await
.with_context(|| format!("create_item({space_path}/{slug}.{SKILL_ITEM_KIND})"))?;
let revision_metadata = revision_metadata_from_manifest(&manifest);
let revision = client
.create_revision(&item.kref, revision_metadata)
.await
.with_context(|| format!("create_revision({})", item.kref))?;
client
.create_artifact(
&revision.kref,
SKILL_ARTIFACT_NAME,
&content_uri,
HashMap::new(),
)
.await
.with_context(|| format!("create_artifact({} -> {content_uri})", revision.kref))?;
client
.tag_revision(&revision.kref, PUBLISHED_TAG)
.await
.with_context(|| format!("tag_revision({}, {PUBLISHED_TAG})", revision.kref))?;
let kref = build_published_kref(memory_project, &slug);
write_kref_to_manifest(&manifest_path, &kref)?;
Ok(SkillRegistration::Registered {
kref,
item_kref: item.kref,
revision_kref: revision.kref,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishedSkillRevision {
pub revision_kref: String,
pub new_content_file: String,
}
pub async fn publish_skill_revision(
skill_dir: &Path,
new_content_file: &Path,
improvement_reason: &str,
client: &KumihoClient,
memory_project: &str,
) -> Result<PublishedSkillRevision> {
let manifest_path = skill_dir.join("SKILL.toml");
if !manifest_path.exists() {
return Err(anyhow!("no SKILL.toml at {}", manifest_path.display()));
}
let manifest = read_manifest(&manifest_path)?;
let kref = manifest.skill.kref.as_deref().ok_or_else(|| {
anyhow!("skill not registered yet (no [skill].kref); run register_skill_with_kumiho first")
})?;
let expected_prefix = format!("kref://{memory_project}/");
if !kref.starts_with(&expected_prefix) {
return Err(anyhow!(
"skill kref {kref:?} does not match configured memory_project {memory_project:?}; \
re-register the skill or update config.kumiho.memory_project"
));
}
let (item_kref, _) = parse_kref_tag(kref);
let item_kref = item_kref.to_string();
let abs = std::fs::canonicalize(new_content_file).with_context(|| {
format!(
"canonicalising new content file: {}",
new_content_file.display()
)
})?;
let content_uri = format_file_uri(&abs);
let mut revision_metadata = revision_metadata_from_manifest(&manifest);
revision_metadata.insert("improvement_reason".into(), improvement_reason.to_string());
revision_metadata.insert("improved_at".into(), chrono::Utc::now().to_rfc3339());
let revision = client
.create_revision(&item_kref, revision_metadata)
.await
.with_context(|| format!("create_revision({item_kref})"))?;
client
.create_artifact(
&revision.kref,
SKILL_ARTIFACT_NAME,
&content_uri,
HashMap::new(),
)
.await
.with_context(|| format!("create_artifact({} -> {content_uri})", revision.kref))?;
match client.get_revision_by_tag(&item_kref, PUBLISHED_TAG).await {
Ok(outgoing) if outgoing.kref != revision.kref => {
if let Err(e) = client
.tag_revision(&outgoing.kref, PREVIOUS_PUBLISHED_TAG)
.await
{
tracing::warn!(
outgoing = %outgoing.kref,
new_revision = %revision.kref,
error = ?e,
"publish_skill_revision: failed to mark outgoing as previous_published; \
rollback target may be unavailable until next publish",
);
}
}
Ok(_) => {
}
Err(e) => {
tracing::warn!(
item_kref = %item_kref,
error = ?e,
"publish_skill_revision: could not fetch current published revision \
to mark as previous_published; rollback target may be unavailable",
);
}
}
client
.tag_revision(&revision.kref, PUBLISHED_TAG)
.await
.with_context(|| format!("tag_revision({}, {PUBLISHED_TAG})", revision.kref))?;
let new_content_file_rel = match sync_published_content_path(skill_dir, client).await? {
SkillContentSync::Updated {
new_content_file, ..
} => new_content_file,
SkillContentSync::AlreadyCurrent => manifest
.skill
.content_file
.clone()
.unwrap_or_else(|| abs.to_string_lossy().into_owned()),
SkillContentSync::NotRegistered => {
return Err(anyhow!(
"publish_skill_revision: SKILL.toml lost its kref between read and sync"
));
}
};
Ok(PublishedSkillRevision {
revision_kref: revision.kref,
new_content_file: new_content_file_rel,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRollback {
pub restored_revision_kref: String,
pub demoted_revision_kref: String,
pub new_content_file: String,
}
pub async fn rollback_skill_revision(
skill_dir: &Path,
client: &KumihoClient,
memory_project: &str,
) -> Result<SkillRollback> {
let manifest_path = skill_dir.join("SKILL.toml");
if !manifest_path.exists() {
return Err(anyhow!("no SKILL.toml at {}", manifest_path.display()));
}
let manifest = read_manifest(&manifest_path)?;
let kref = manifest.skill.kref.as_deref().ok_or_else(|| {
anyhow!("skill not registered yet (no [skill].kref); nothing to roll back")
})?;
let expected_prefix = format!("kref://{memory_project}/");
if !kref.starts_with(&expected_prefix) {
return Err(anyhow!(
"skill kref {kref:?} does not match configured memory_project {memory_project:?}; \
re-register the skill or update config.kumiho.memory_project"
));
}
let (item_kref, _) = parse_kref_tag(kref);
let item_kref = item_kref.to_string();
let target = client
.get_revision_by_tag(&item_kref, PREVIOUS_PUBLISHED_TAG)
.await
.with_context(|| format!("get_revision_by_tag({item_kref}, {PREVIOUS_PUBLISHED_TAG})"))?;
let current = client
.get_revision_by_tag(&item_kref, PUBLISHED_TAG)
.await
.with_context(|| format!("get_revision_by_tag({item_kref}, {PUBLISHED_TAG})"))?;
if target.kref == current.kref {
return Err(anyhow!(
"rollback target {target} is already the current published revision; nothing to roll back",
target = target.kref,
));
}
client
.tag_revision(&target.kref, PUBLISHED_TAG)
.await
.with_context(|| format!("tag_revision({}, {PUBLISHED_TAG})", target.kref))?;
let new_content_file_rel = match sync_published_content_path(skill_dir, client).await? {
SkillContentSync::Updated {
new_content_file, ..
} => new_content_file,
SkillContentSync::AlreadyCurrent => manifest.skill.content_file.clone().unwrap_or_default(),
SkillContentSync::NotRegistered => {
return Err(anyhow!(
"rollback_skill_revision: SKILL.toml lost its kref between read and sync"
));
}
};
Ok(SkillRollback {
restored_revision_kref: target.kref,
demoted_revision_kref: current.kref,
new_content_file: new_content_file_rel,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillContentSync {
NotRegistered,
AlreadyCurrent,
Updated {
manifest: PathBuf,
new_content_file: String,
},
}
pub async fn sync_published_content_path(
skill_dir: &Path,
client: &KumihoClient,
) -> Result<SkillContentSync> {
let manifest_path = skill_dir.join("SKILL.toml");
if !manifest_path.exists() {
return Err(anyhow!("no SKILL.toml at {}", manifest_path.display()));
}
let manifest = read_manifest(&manifest_path)?;
let Some(kref) = manifest.skill.kref.as_ref() else {
return Ok(SkillContentSync::NotRegistered);
};
let (item_kref, tag) = parse_kref_tag(kref);
let item_kref = item_kref.to_string();
let tag = tag.unwrap_or(PUBLISHED_TAG).to_string();
let revision = client
.get_revision_by_tag(&item_kref, &tag)
.await
.with_context(|| format!("get_revision_by_tag({item_kref}, {tag})"))?;
let artifacts = client
.get_artifacts(&revision.kref)
.await
.with_context(|| format!("get_artifacts({})", revision.kref))?;
let artifact = artifacts
.iter()
.find(|a| a.name == SKILL_ARTIFACT_NAME)
.or_else(|| artifacts.first())
.ok_or_else(|| anyhow!("revision {} has no artifacts", revision.kref))?;
let abs = parse_file_uri(&artifact.location).ok_or_else(|| {
anyhow!(
"artifact location {:?} is not a file:// URI; cannot sync content_file",
artifact.location
)
})?;
let rel = match abs.strip_prefix(skill_dir) {
Ok(stripped) => stripped.to_string_lossy().into_owned(),
Err(_) => abs.to_string_lossy().into_owned(),
};
if manifest.skill.content_file.as_deref() == Some(rel.as_str()) {
return Ok(SkillContentSync::AlreadyCurrent);
}
let mut updated = manifest;
updated.skill.content_file = Some(rel.clone());
let serialized =
toml::to_string_pretty(&updated).context("serializing SKILL.toml after sync")?;
std::fs::write(&manifest_path, serialized.as_bytes())
.with_context(|| format!("writing {}", manifest_path.display()))?;
Ok(SkillContentSync::Updated {
manifest: manifest_path,
new_content_file: rel,
})
}
fn parse_kref_tag(kref: &str) -> (&str, Option<&str>) {
let Some((base, query)) = kref.split_once('?') else {
return (kref, None);
};
for part in query.split('&') {
if let Some(value) = part.strip_prefix("t=") {
return (base, Some(value));
}
}
(base, None)
}
fn parse_file_uri(uri: &str) -> Option<PathBuf> {
let path_str = uri.strip_prefix("file://")?;
Some(PathBuf::from(path_str.replace("%25", "%")))
}
fn read_manifest(path: &Path) -> Result<SkillManifest> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading SKILL.toml: {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parsing SKILL.toml: {}", path.display()))
}
fn resolve_content_file(skill_dir: &Path, manifest: &SkillManifest) -> Result<PathBuf> {
let rel = manifest.skill.content_file.as_deref().ok_or_else(|| {
anyhow!(
"skill {:?} has no content_file after migration; cannot register",
manifest.skill.name
)
})?;
let path = skill_dir.join(rel);
if !path.exists() {
return Err(anyhow!(
"skill content_file does not exist: {}",
path.display()
));
}
std::fs::canonicalize(&path).with_context(|| format!("canonicalising {}", path.display()))
}
fn revision_metadata_from_manifest(manifest: &SkillManifest) -> HashMap<String, String> {
let mut meta = HashMap::with_capacity(5);
meta.insert("name".into(), manifest.skill.name.clone());
meta.insert("description".into(), manifest.skill.description.clone());
meta.insert("version".into(), manifest.skill.version.clone());
if let Some(a) = &manifest.skill.author {
meta.insert("author".into(), a.clone());
}
if !manifest.skill.tags.is_empty() {
meta.insert("tags".into(), manifest.skill.tags.join(","));
}
meta
}
fn format_file_uri(path: &Path) -> String {
let s = path.to_string_lossy();
format!("file://{}", s.replace('%', "%25"))
}
fn write_kref_to_manifest(path: &Path, kref: &str) -> Result<()> {
let mut manifest = read_manifest(path)?;
manifest.skill.kref = Some(kref.to_string());
let serialized =
toml::to_string_pretty(&manifest).context("serializing SKILL.toml with kref")?;
std::fs::write(path, serialized.as_bytes())
.with_context(|| format!("writing SKILL.toml: {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_published_kref_uses_memory_project_and_slug() {
let kref = build_published_kref("CognitiveMemory", "code-review");
assert_eq!(
kref,
"kref://CognitiveMemory/Skills/code-review.skilldef?t=published"
);
}
#[test]
fn build_published_kref_respects_custom_memory_project() {
let kref = build_published_kref("MyOrgMemory", "deploy-runner");
assert_eq!(
kref,
"kref://MyOrgMemory/Skills/deploy-runner.skilldef?t=published"
);
}
#[test]
fn format_file_uri_basic() {
let p = PathBuf::from("/Users/neo/.construct/workspace/skills/foo/contents/r1.md");
assert_eq!(
format_file_uri(&p),
"file:///Users/neo/.construct/workspace/skills/foo/contents/r1.md"
);
}
#[test]
fn format_file_uri_escapes_percent() {
let p = PathBuf::from("/tmp/has%percent");
assert_eq!(format_file_uri(&p), "file:///tmp/has%25percent");
}
#[test]
fn revision_metadata_from_manifest_includes_required_fields() {
let manifest = SkillManifest {
skill: super::super::SkillMeta {
name: "demo".into(),
description: "demo skill".into(),
version: "0.1.0".into(),
author: Some("alice".into()),
tags: vec!["safety".into(), "ops".into()],
content_file: Some("contents/r1.md".into()),
kref: None,
},
tools: vec![],
prompts: vec![],
};
let meta = revision_metadata_from_manifest(&manifest);
assert_eq!(meta.get("name").map(String::as_str), Some("demo"));
assert_eq!(
meta.get("description").map(String::as_str),
Some("demo skill")
);
assert_eq!(meta.get("version").map(String::as_str), Some("0.1.0"));
assert_eq!(meta.get("author").map(String::as_str), Some("alice"));
assert_eq!(meta.get("tags").map(String::as_str), Some("safety,ops"));
}
#[test]
fn revision_metadata_omits_optional_when_absent() {
let manifest = SkillManifest {
skill: super::super::SkillMeta {
name: "minimal".into(),
description: "x".into(),
version: "0.1.0".into(),
author: None,
tags: vec![],
content_file: Some("contents/r1.md".into()),
kref: None,
},
tools: vec![],
prompts: vec![],
};
let meta = revision_metadata_from_manifest(&manifest);
assert!(!meta.contains_key("author"));
assert!(!meta.contains_key("tags"));
}
#[test]
fn write_kref_to_manifest_round_trips_other_fields() {
let dir = tempfile::tempdir().unwrap();
let manifest_path = dir.path().join("SKILL.toml");
std::fs::write(
&manifest_path,
r#"[skill]
name = "round-trip"
description = "preserves other fields"
version = "0.4.2"
content_file = "contents/r1.md"
"#,
)
.unwrap();
write_kref_to_manifest(
&manifest_path,
"kref://CognitiveMemory/Skills/round-trip.skilldef?t=published",
)
.unwrap();
let raw = std::fs::read_to_string(&manifest_path).unwrap();
assert!(raw.contains("name = \"round-trip\""));
assert!(raw.contains("version = \"0.4.2\""));
assert!(raw.contains("content_file = \"contents/r1.md\""));
assert!(
raw.contains(
"kref = \"kref://CognitiveMemory/Skills/round-trip.skilldef?t=published\""
)
);
}
#[test]
fn resolve_content_file_errors_when_path_missing() {
let dir = tempfile::tempdir().unwrap();
let manifest = SkillManifest {
skill: super::super::SkillMeta {
name: "missing".into(),
description: "x".into(),
version: "0.1.0".into(),
author: None,
tags: vec![],
content_file: Some("contents/does-not-exist.md".into()),
kref: None,
},
tools: vec![],
prompts: vec![],
};
let err = resolve_content_file(dir.path(), &manifest).unwrap_err();
assert!(err.to_string().contains("does not exist"));
}
#[test]
fn resolve_content_file_errors_when_not_set() {
let dir = tempfile::tempdir().unwrap();
let manifest = SkillManifest {
skill: super::super::SkillMeta {
name: "no-pointer".into(),
description: "x".into(),
version: "0.1.0".into(),
author: None,
tags: vec![],
content_file: None,
kref: None,
},
tools: vec![],
prompts: vec![],
};
let err = resolve_content_file(dir.path(), &manifest).unwrap_err();
assert!(err.to_string().contains("no content_file"));
}
#[test]
fn parse_kref_tag_extracts_published() {
let (base, tag) = parse_kref_tag("kref://CognitiveMemory/Skills/foo.skilldef?t=published");
assert_eq!(base, "kref://CognitiveMemory/Skills/foo.skilldef");
assert_eq!(tag, Some("published"));
}
#[test]
fn parse_kref_tag_extracts_arbitrary_tag() {
let (base, tag) = parse_kref_tag("kref://CognitiveMemory/Skills/foo.skilldef?t=stable");
assert_eq!(base, "kref://CognitiveMemory/Skills/foo.skilldef");
assert_eq!(tag, Some("stable"));
}
#[test]
fn parse_kref_tag_returns_none_when_query_absent() {
let (base, tag) = parse_kref_tag("kref://CognitiveMemory/Skills/foo.skilldef");
assert_eq!(base, "kref://CognitiveMemory/Skills/foo.skilldef");
assert_eq!(tag, None);
}
#[test]
fn parse_kref_tag_skips_unrelated_query_params() {
let (base, tag) = parse_kref_tag("kref://CognitiveMemory/Skills/foo.skilldef?r=3");
assert_eq!(base, "kref://CognitiveMemory/Skills/foo.skilldef");
assert_eq!(tag, None);
}
#[test]
fn parse_kref_tag_finds_t_among_multiple_params() {
let (base, tag) = parse_kref_tag("kref://CognitiveMemory/Skills/foo.skilldef?r=3&t=stable");
assert_eq!(base, "kref://CognitiveMemory/Skills/foo.skilldef");
assert_eq!(tag, Some("stable"));
}
#[test]
fn parse_file_uri_strips_scheme_and_unescapes() {
let p = parse_file_uri("file:///tmp/x").unwrap();
assert_eq!(p, PathBuf::from("/tmp/x"));
let p = parse_file_uri("file:///tmp/has%25percent").unwrap();
assert_eq!(p, PathBuf::from("/tmp/has%percent"));
}
#[test]
fn parse_file_uri_returns_none_for_non_file_scheme() {
assert!(parse_file_uri("http://example.com").is_none());
assert!(parse_file_uri("/no/scheme").is_none());
}
#[test]
fn file_uri_round_trips_through_parse() {
let original = PathBuf::from("/Users/neo/.construct/workspace/skills/foo/contents/r1.md");
let uri = format_file_uri(&original);
let parsed = parse_file_uri(&uri).expect("round trip");
assert_eq!(parsed, original);
let weird = PathBuf::from("/tmp/has%percent");
let uri = format_file_uri(&weird);
let parsed = parse_file_uri(&uri).expect("round trip");
assert_eq!(parsed, weird);
}
#[tokio::test]
async fn publish_skill_revision_errors_when_no_skill_toml() {
let dir = tempfile::tempdir().unwrap();
let client = KumihoClient::new("http://127.0.0.1:1".into(), "test".into());
let err = publish_skill_revision(
dir.path(),
&dir.path().join("contents/r2.md"),
"test",
&client,
"CognitiveMemory",
)
.await
.unwrap_err();
assert!(err.to_string().contains("no SKILL.toml"), "got: {err}");
}
#[tokio::test]
async fn publish_skill_revision_errors_when_no_kref_in_manifest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("SKILL.toml"),
r#"[skill]
name = "unregistered"
description = "no kref yet"
version = "0.1.0"
content_file = "contents/r1.md"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("contents")).unwrap();
std::fs::write(dir.path().join("contents/r1.md"), "body").unwrap();
let new_file = dir.path().join("contents/r2.md");
std::fs::write(&new_file, "improved").unwrap();
let client = KumihoClient::new("http://127.0.0.1:1".into(), "test".into());
let err = publish_skill_revision(dir.path(), &new_file, "test", &client, "CognitiveMemory")
.await
.unwrap_err();
assert!(
err.to_string().contains("skill not registered yet"),
"got: {err}"
);
}
#[tokio::test]
async fn publish_skill_revision_errors_on_project_mismatch() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("SKILL.toml"),
r#"[skill]
name = "moved"
description = "x"
version = "0.1.0"
content_file = "contents/r1.md"
kref = "kref://OldProject/Skills/moved.skilldef?t=published"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("contents")).unwrap();
std::fs::write(dir.path().join("contents/r1.md"), "body").unwrap();
let new_file = dir.path().join("contents/r2.md");
std::fs::write(&new_file, "improved").unwrap();
let client = KumihoClient::new("http://127.0.0.1:1".into(), "test".into());
let err = publish_skill_revision(dir.path(), &new_file, "test", &client, "NewProject")
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not match configured memory_project"),
"got: {msg}"
);
assert!(msg.contains("OldProject"), "got: {msg}");
assert!(msg.contains("NewProject"), "got: {msg}");
}
#[tokio::test]
async fn publish_skill_revision_errors_when_new_file_missing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("SKILL.toml"),
r#"[skill]
name = "ghost"
description = "x"
version = "0.1.0"
content_file = "contents/r1.md"
kref = "kref://CognitiveMemory/Skills/ghost.skilldef?t=published"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("contents")).unwrap();
std::fs::write(dir.path().join("contents/r1.md"), "body").unwrap();
let phantom = dir.path().join("contents/r2-does-not-exist.md");
let client = KumihoClient::new("http://127.0.0.1:1".into(), "test".into());
let err = publish_skill_revision(dir.path(), &phantom, "test", &client, "CognitiveMemory")
.await
.unwrap_err();
assert!(
err.to_string().contains("canonicalising new content file"),
"got: {err}"
);
}
#[tokio::test]
async fn rollback_skill_revision_errors_when_no_skill_toml() {
let dir = tempfile::tempdir().unwrap();
let client = KumihoClient::new("http://127.0.0.1:1".into(), "test".into());
let err = rollback_skill_revision(dir.path(), &client, "CognitiveMemory")
.await
.unwrap_err();
assert!(err.to_string().contains("no SKILL.toml"), "got: {err}");
}
#[tokio::test]
async fn rollback_skill_revision_errors_when_no_kref_in_manifest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("SKILL.toml"),
r#"[skill]
name = "unregistered"
description = "no kref yet"
version = "0.1.0"
content_file = "contents/r1.md"
"#,
)
.unwrap();
let client = KumihoClient::new("http://127.0.0.1:1".into(), "test".into());
let err = rollback_skill_revision(dir.path(), &client, "CognitiveMemory")
.await
.unwrap_err();
assert!(
err.to_string().contains("nothing to roll back"),
"got: {err}"
);
}
#[tokio::test]
async fn rollback_skill_revision_errors_on_project_mismatch() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("SKILL.toml"),
r#"[skill]
name = "moved"
description = "x"
version = "0.1.0"
content_file = "contents/r1.md"
kref = "kref://OldProject/Skills/moved.skilldef?t=published"
"#,
)
.unwrap();
let client = KumihoClient::new("http://127.0.0.1:1".into(), "test".into());
let err = rollback_skill_revision(dir.path(), &client, "NewProject")
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not match configured memory_project"),
"got: {msg}"
);
assert!(msg.contains("OldProject"), "got: {msg}");
assert!(msg.contains("NewProject"), "got: {msg}");
}
#[test]
fn previous_published_tag_is_distinct_from_published() {
assert_ne!(PUBLISHED_TAG, PREVIOUS_PUBLISHED_TAG);
assert_eq!(PREVIOUS_PUBLISHED_TAG, "previous_published");
}
}