Skip to repository content1038 lines · 39.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:17:02.937Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
rules_to_skills_migration.rs
1//! One-time migration from Rules (stored in the user's `PromptStore`
2//! LMDB database) to two destinations:
3//!
4//! * **Non-Default Rules → global Agent Skills** under
5//! `~/.agents/skills/<slug>/SKILL.md`. Non-Default Rules were only ever
6//! included in a conversation when the user explicitly invoked them by
7//! name, which maps cleanly onto Skills with
8//! `disable-model-invocation: true` (slash-only, never auto-suggested
9//! to the model). See [`migrate_non_default_rules_to_skills`].
10//!
11//! * **Default Rules → global AGENTS.md** at the platform-appropriate
12//! path (see [`paths::agents_file`]). Default Rules were auto-included
13//! in every conversation; the global AGENTS.md is loaded into the
14//! system prompt of every conversation, so the migration target
15//! preserves the behavior. Each rule is appended under an `## H2`
16//! heading containing the rule's title. See
17//! [`migrate_default_rules_to_agents_md`].
18//!
19//! **Customized built-in prompts** (currently just
20//! [`BuiltInPrompt::CommitMessage`]) are treated the same as Default
21//! user Rules — if the user has edited the body away from the
22//! built-in's `default_content()`, the edited body is appended to
23//! AGENTS.md ahead of any user Default Rules. Uncustomized built-ins
24//! (still using Zed's shipped default content) are skipped so we don't
25//! pollute AGENTS.md with text the user never wrote.
26//!
27//! Both migrations are gated by a single global "migration already ran"
28//! flag persisted in [`GlobalKeyValueStore`] — keyed by
29//! [`MIGRATION_DONE_KEY`], so a shared home directory only gets
30//! populated once per machine even across release channels.
31//!
32//! The migration is intentionally non-destructive: rule rows in the LMDB
33//! database are left in place after the migration. That way users can
34//! still see and edit their Rules via the existing UI, and a user who
35//! downgrades to a Zed build without skills support won't lose anything.
36
37use std::collections::HashSet;
38use std::path::{Path, PathBuf};
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, Ordering};
41
42use agent_skills::{
43 SKILL_FILE_NAME, global_skills_dir, parse_skill_file_content, slugify_skill_name,
44};
45use anyhow::{Context as _, Result};
46use db::kvp::GlobalKeyValueStore;
47use fs::Fs;
48use futures::StreamExt as _;
49use gpui::{App, AsyncApp, Entity, Task, TaskExt as _};
50use serde::{Deserialize, Serialize};
51use util::ResultExt as _;
52
53use crate::{BuiltInPrompt, PromptId, PromptStore};
54use strum::IntoEnumIterator as _;
55
56/// Global KVP flag: set to `"1"` once the migration has been considered
57/// for this machine, regardless of whether any rules were actually
58/// migrated. Used to short-circuit the migration on every subsequent
59/// launch.
60pub const MIGRATION_DONE_KEY: &str = "rules_to_skills_migration_done";
61
62/// Global KVP key for the JSON-serialized [`MigrationResult`] produced by
63/// the most recent migration run — the lists of source-Rule titles that
64/// were migrated to each destination. The skills announcement toast
65/// reads this to decide whether to mention the migration in its copy.
66pub const MIGRATION_RESULT_KEY: &str = "rules_to_skills_migration_result";
67
68/// A persistent record of what the rules-to-skills migration actually
69/// migrated. Persisted in [`GlobalKeyValueStore`] under
70/// [`MIGRATION_RESULT_KEY`] and read back by the skills announcement
71/// toast so it can tailor its copy to users who actually had Rules to
72/// migrate.
73///
74/// All three lists hold the *original* user-facing Rule titles, not the
75/// derived skill slug or any other transformed identifier — those are
76/// what users would recognize.
77#[derive(Default, Debug, Clone, Serialize, Deserialize)]
78pub struct MigrationResult {
79 /// Non-Default Rules that were turned into global Skills under
80 /// `~/.agents/skills/`.
81 #[serde(default)]
82 pub skill_names: Vec<String>,
83 /// Default Rules that were appended to the global AGENTS.md.
84 #[serde(default)]
85 pub agents_md_names: Vec<String>,
86 /// Customized built-in prompts whose edited bodies were appended to
87 /// the top of the global AGENTS.md.
88 #[serde(default)]
89 pub customized_builtins: Vec<String>,
90}
91
92impl MigrationResult {
93 /// `true` if the migration didn't actually move any Rule anywhere —
94 /// i.e. the user had no Rules of any kind to migrate. The skills
95 /// announcement toast uses this to omit the migration-flavored
96 /// bullet for users who never had any Rules.
97 pub fn is_empty(&self) -> bool {
98 self.skill_names.is_empty()
99 && self.agents_md_names.is_empty()
100 && self.customized_builtins.is_empty()
101 }
102}
103
104/// Read the most recently persisted [`MigrationResult`], if any. Returns
105/// `None` when the migration hasn't run on this machine yet or the
106/// persisted blob couldn't be parsed.
107pub fn migration_result() -> Option<MigrationResult> {
108 let json = GlobalKeyValueStore::global()
109 .read_kvp(MIGRATION_RESULT_KEY)
110 .log_err()
111 .flatten()?;
112 serde_json::from_str(&json).log_err()
113}
114
115/// Placeholder description written into the YAML frontmatter of migrated
116/// skills. Migrated skills are model-disabled, so the model never sees
117/// this string — it exists only because the SKILL.md schema requires a
118/// non-empty `description`.
119const PLACEHOLDER_DESCRIPTION: &str = "(no description)";
120
121/// Returns `true` if a previous launch has already completed the
122/// rules-to-skills migration check.
123pub fn migration_done() -> bool {
124 GlobalKeyValueStore::global()
125 .read_kvp(MIGRATION_DONE_KEY)
126 .log_err()
127 .flatten()
128 .is_some()
129}
130
131/// Process-lifetime guard ensuring the migration task is spawned at most
132/// once per process. The KVP-backed [`migration_done`] flag handles the
133/// across-launch idempotency, but it isn't enough on its own: this
134/// function is wired to `cx.on_flags_ready`, which is implemented via
135/// `observe_global::<FeatureFlagStore>` and therefore fires every time
136/// the flag store mutates. At startup that can happen several times in
137/// rapid succession (window construction, settings observers touching
138/// globals, etc.). Without this guard, each of those firings would see
139/// `migration_done() == false` (because the first in-flight spawn hasn't
140/// written the KVP yet), spawn its own task, and the tasks would race —
141/// each one calling `pick_available_skill_dir` and dutifully picking the
142/// next free `-N` suffix because its sibling task already created the
143/// previous one. The visible result is N duplicate `<rule>-2`,
144/// `<rule>-3`, … directories per rule, where N is the number of times
145/// the callback fired before the first spawn finished writing
146/// `MIGRATION_DONE_KEY`.
147static MIGRATION_TASK_SPAWNED: AtomicBool = AtomicBool::new(false);
148
149/// Migrate non-Default user rules to global Skills, if not already done.
150///
151/// Safe to call on every startup — short-circuits immediately when the
152/// migration has already run or when another invocation in this process
153/// has already started it.
154pub fn migrate_rules_to_skills_if_needed(fs: Arc<dyn Fs>, cx: &mut App) {
155 if migration_done() {
156 log::info!("Rules-to-skills migration already marked done; skipping startup migration");
157 return;
158 }
159 // Atomically claim the right to spawn the migration task. If another
160 // invocation has already claimed it, we bail without spawning a
161 // second one — see the doc comment on `MIGRATION_TASK_SPAWNED` for
162 // why the KVP-backed check above isn't sufficient on its own.
163 if MIGRATION_TASK_SPAWNED
164 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
165 .is_err()
166 {
167 log::info!("Rules-to-skills migration already running; skipping duplicate startup request");
168 return;
169 }
170
171 spawn_rules_to_skills_migration(fs, cx).detach_and_log_err(cx);
172}
173
174pub fn rerun_rules_to_skills_migration(
175 fs: Arc<dyn Fs>,
176 cx: &mut App,
177) -> Task<Result<MigrationResult>> {
178 log::info!("Forcing rules-to-skills migration rerun from command");
179 spawn_rules_to_skills_migration(fs, cx)
180}
181
182fn spawn_rules_to_skills_migration(fs: Arc<dyn Fs>, cx: &mut App) -> Task<Result<MigrationResult>> {
183 let prompt_store = PromptStore::global(cx);
184 cx.spawn(async move |cx| {
185 let prompt_store = prompt_store.await.context("loading prompt store")?;
186 run_rules_to_skills_migration(fs.as_ref(), &prompt_store, cx).await
187 })
188}
189
190async fn run_rules_to_skills_migration(
191 fs: &dyn Fs,
192 prompt_store: &Entity<PromptStore>,
193 cx: &mut AsyncApp,
194) -> Result<MigrationResult> {
195 log::info!(
196 "Starting rules-to-skills migration; skills_dir={}, agents_md={}, prompts_dir={}",
197 global_skills_dir().display(),
198 paths::agents_file().display(),
199 paths::prompts_dir().display()
200 );
201
202 // Snapshot the (id, title) pairs for every user rule, split by
203 // whether it's a Default rule or not. BuiltIn prompts (e.g. the
204 // commit-message prompt) are excluded — they're not user-facing
205 // "Rules" in the agent sense.
206 let (default_rules, non_default_rules) = prompt_store.read_with(cx, |store, _| {
207 let mut default = Vec::new();
208 let mut non_default = Vec::new();
209 for metadata in store.all_prompt_metadata() {
210 if metadata.id.as_user().is_none() {
211 continue;
212 }
213 let Some(title) = metadata.title.as_ref().map(|t| t.to_string()) else {
214 continue;
215 };
216 if metadata.default {
217 default.push((metadata.id, title));
218 } else {
219 non_default.push((metadata.id, title));
220 }
221 }
222 (default, non_default)
223 });
224 log::info!(
225 "Rules-to-skills migration found {} non-default rules and {} default rules",
226 non_default_rules.len(),
227 default_rules.len()
228 );
229
230 let mut result = MigrationResult::default();
231
232 result.skill_names =
233 migrate_non_default_rules_to_skills(fs, prompt_store, cx, non_default_rules).await;
234
235 let (agents_md_names, customized_builtins) = migrate_default_rules_to_agents_md(
236 fs,
237 paths::agents_file(),
238 prompt_store,
239 cx,
240 default_rules,
241 )
242 .await;
243 result.agents_md_names = agents_md_names;
244 result.customized_builtins = customized_builtins;
245
246 // Persist the result BEFORE the done flag: if we crash between
247 // these two writes the next launch will see `done == false` and
248 // re-run, picking up the same (deterministic) result. The AGENTS.md
249 // append path is idempotent, so a retry won't duplicate entries that
250 // were already written before the crash.
251 write_migration_result(&result).await;
252 mark_migration_done().await;
253 log::info!(
254 "Finished rules-to-skills migration; skill_names={}, agents_md_names={}, customized_builtins={}",
255 result.skill_names.len(),
256 result.agents_md_names.len(),
257 result.customized_builtins.len()
258 );
259 Ok(result)
260}
261
262/// Returns `true` if `body` (the result of `PromptStore::load` for the
263/// given built-in) differs from the built-in's shipped `default_content`.
264/// Customization detection is done by trimmed-string comparison so that
265/// whitespace-only differences (e.g. trailing newlines) don't count as a
266/// customization.
267fn is_customized_builtin_body(builtin: BuiltInPrompt, body: &str) -> bool {
268 body.trim() != builtin.default_content().trim()
269}
270
271/// Convert every non-Default user rule into a global Agent Skill on disk.
272/// Returns the titles of rules that were successfully migrated (i.e. the
273/// ones the user will recognize when the announcement modal lists
274/// "these Rules have been migrated to Skills").
275async fn migrate_non_default_rules_to_skills(
276 fs: &dyn Fs,
277 prompt_store: &Entity<PromptStore>,
278 cx: &mut AsyncApp,
279 rules: Vec<(PromptId, String)>,
280) -> Vec<String> {
281 if rules.is_empty() {
282 return Vec::new();
283 }
284 let skills_dir = global_skills_dir();
285 let mut existing_skill_contents = existing_skill_contents(fs, &skills_dir).await;
286 let mut migrated = Vec::with_capacity(rules.len());
287 for (id, title) in rules {
288 let body = match load_rule_body(prompt_store, cx, id, &title).await {
289 Some(body) => body,
290 None => continue,
291 };
292 let Some(slug) = slugify_skill_name(&title) else {
293 log::warn!(
294 "Skipping rule {title:?}: title contains no characters \
295 valid for a skill name"
296 );
297 continue;
298 };
299 match write_migrated_skill(fs, &skills_dir, &slug, &body, &mut existing_skill_contents)
300 .await
301 {
302 Ok(()) => migrated.push(title),
303 Err(err) => {
304 log::warn!("Failed to write skill for rule {title:?}: {err:#}");
305 }
306 }
307 }
308 migrated
309}
310
311#[derive(Clone, Copy)]
312enum AgentsMdMigrationEntryKind {
313 CustomizedBuiltin,
314 DefaultUserRule,
315}
316
317/// Append all auto-included Rules to the global `AGENTS.md`, creating it
318/// if necessary. Each rule lands under an `## H2` heading containing its
319/// title, with the rule body underneath.
320///
321/// The appended block contains, in order:
322///
323/// 1. Each [`BuiltInPrompt`] the user has customized (uncustomized
324/// built-ins are skipped so we don't write Zed's shipped default text
325/// into the user's personal AGENTS.md).
326/// 2. Each user Default Rule, in the order given.
327///
328/// Returns `(default_user_rule_titles, customized_builtin_titles)` of
329/// what actually got appended, for the announcement modal to surface.
330async fn migrate_default_rules_to_agents_md(
331 fs: &dyn Fs,
332 agents_md_path: &Path,
333 prompt_store: &Entity<PromptStore>,
334 cx: &mut AsyncApp,
335 default_user_rules: Vec<(PromptId, String)>,
336) -> (Vec<String>, Vec<String>) {
337 let mut entries: Vec<(String, String, AgentsMdMigrationEntryKind)> = Vec::new();
338
339 // Customized built-ins come first.
340 for builtin in BuiltInPrompt::iter() {
341 let id = PromptId::BuiltIn(builtin);
342 let title = builtin.title().to_string();
343 let Some(body) = load_rule_body(prompt_store, cx, id, &title).await else {
344 continue;
345 };
346 if !is_customized_builtin_body(builtin, &body) {
347 continue;
348 }
349 entries.push((title, body, AgentsMdMigrationEntryKind::CustomizedBuiltin));
350 }
351
352 // Then user Default Rules.
353 for (id, title) in default_user_rules {
354 let Some(body) = load_rule_body(prompt_store, cx, id, &title).await else {
355 continue;
356 };
357 entries.push((title, body, AgentsMdMigrationEntryKind::DefaultUserRule));
358 }
359
360 if entries.is_empty() {
361 return (Vec::new(), Vec::new());
362 }
363
364 let entries_for_append = entries
365 .iter()
366 .map(|(title, body, _)| (title.clone(), body.clone()))
367 .collect::<Vec<_>>();
368 let appended_indices =
369 match append_default_rules_to_agents_md(fs, agents_md_path, &entries_for_append).await {
370 Ok(appended_indices) => appended_indices,
371 Err(err) => {
372 log::warn!("Failed to append default rules to AGENTS.md: {err:#}");
373 // Treat a write failure as "nothing was actually migrated" so the
374 // announcement modal doesn't lie about what's in AGENTS.md.
375 return (Vec::new(), Vec::new());
376 }
377 };
378
379 let mut default_user_titles = Vec::new();
380 let mut customized_builtin_titles = Vec::new();
381 for index in appended_indices {
382 let (title, _, kind) = &entries[index];
383 match kind {
384 AgentsMdMigrationEntryKind::CustomizedBuiltin => {
385 customized_builtin_titles.push(title.clone());
386 }
387 AgentsMdMigrationEntryKind::DefaultUserRule => {
388 default_user_titles.push(title.clone());
389 }
390 }
391 }
392
393 (default_user_titles, customized_builtin_titles)
394}
395
396async fn load_rule_body(
397 prompt_store: &Entity<PromptStore>,
398 cx: &mut AsyncApp,
399 id: PromptId,
400 title: &str,
401) -> Option<String> {
402 let task = prompt_store.update(cx, |store, cx| store.load(id, cx));
403 match task.await {
404 Ok(body) => Some(body),
405 Err(err) => {
406 log::warn!("Skipping rule {title:?}: failed to load body: {err:#}");
407 None
408 }
409 }
410}
411
412/// Build the markdown text to append for the given (title, body) rules
413/// and write it to `agents_md_path`, preserving any existing AGENTS.md
414/// content above the appended block. Returns the indices of rules that
415/// were actually appended; entries already present verbatim are skipped.
416async fn append_default_rules_to_agents_md(
417 fs: &dyn Fs,
418 agents_md_path: &Path,
419 rules: &[(String, String)],
420) -> Result<Vec<usize>> {
421 if rules.is_empty() {
422 return Ok(Vec::new());
423 }
424
425 // `fs.load` errors when the file is missing OR unreadable; treat both
426 // as "no existing content" so the file gets (re-)created from the
427 // migrated text.
428 let existing_trimmed = fs
429 .load(agents_md_path)
430 .await
431 .ok()
432 .map(|s| s.trim().to_string());
433
434 let mut appended = String::new();
435 let mut appended_indices = Vec::new();
436 for (index, rule) in rules.iter().enumerate() {
437 let section = format_default_rules_section(std::slice::from_ref(rule));
438 if existing_trimmed
439 .as_deref()
440 .is_some_and(|existing| existing.contains(section.trim()))
441 {
442 log::info!(
443 "Skipping AGENTS.md migration entry {:?}: matching section already exists",
444 rule.0
445 );
446 continue;
447 }
448 if !appended.is_empty() {
449 appended.push_str("\n\n");
450 }
451 appended.push_str(§ion);
452 appended_indices.push(index);
453 }
454
455 if appended_indices.is_empty() {
456 return Ok(Vec::new());
457 }
458
459 let final_contents = match existing_trimmed.as_deref() {
460 Some(existing) if !existing.is_empty() => format!("{existing}\n\n{appended}\n"),
461 _ => format!("{appended}\n"),
462 };
463
464 fs.write(agents_md_path, final_contents.as_bytes()).await?;
465 Ok(appended_indices)
466}
467
468/// Build the markdown text representing the migrated Default Rules block.
469/// Each rule contributes an `## H2` heading followed by its body, with
470/// rules separated by blank lines.
471fn format_default_rules_section(rules: &[(String, String)]) -> String {
472 let mut out = String::new();
473 for (title, body) in rules {
474 if !out.is_empty() {
475 out.push_str("\n\n");
476 }
477 out.push_str("## ");
478 out.push_str(title);
479 out.push_str("\n\n");
480 out.push_str(body.trim());
481 }
482 out
483}
484
485async fn mark_migration_done() {
486 GlobalKeyValueStore::global()
487 .write_kvp(MIGRATION_DONE_KEY.into(), "1".into())
488 .await
489 .log_err();
490}
491
492async fn write_migration_result(result: &MigrationResult) {
493 let json = match serde_json::to_string(result) {
494 Ok(json) => json,
495 Err(err) => {
496 log::warn!("Failed to serialize rules-to-skills migration result: {err:#}");
497 return;
498 }
499 };
500 GlobalKeyValueStore::global()
501 .write_kvp(MIGRATION_RESULT_KEY.into(), json)
502 .await
503 .log_err();
504}
505
506/// Write a single migrated rule to disk as `<skills_dir>/<name>/SKILL.md`.
507///
508/// Three cases:
509///
510/// 1. Any existing skill file already matches the content we'd write, or
511/// already has the same instruction body as this rule. Skip silently;
512/// don't create a duplicate skill.
513/// 2. `<skills_dir>/<slug>/` doesn't exist — happy path. Create it and
514/// write the SKILL.md there.
515/// 3. `<skills_dir>/<slug>/` exists with *different* content (a real
516/// name collision or a hand-edited skill we shouldn't touch). Pick
517/// the first free `<slug>-2`, `<slug>-3`, … and write there with the
518/// suffixed name baked into the SKILL.md frontmatter.
519async fn write_migrated_skill(
520 fs: &dyn Fs,
521 skills_dir: &Path,
522 slug: &str,
523 body: &str,
524 existing_skill_contents: &mut ExistingSkillContents,
525) -> Result<()> {
526 let trimmed_body = body.trim();
527 if existing_skill_contents.bodies.contains(trimmed_body) {
528 return Ok(());
529 }
530
531 // Cases 2 and 3: find a free directory (the primary if free,
532 // otherwise a `-N` suffix) and write the SKILL.md there.
533 let (name, dir) = pick_available_skill_dir(fs, skills_dir, slug).await?;
534 let content = format_skill_file(&name, body);
535 let trimmed_content = content.trim();
536 if existing_skill_contents.files.contains(trimmed_content) {
537 return Ok(());
538 }
539
540 fs.create_dir(&dir).await?;
541 let skill_file_path = dir.join(SKILL_FILE_NAME);
542 fs.write(&skill_file_path, content.as_bytes()).await?;
543 existing_skill_contents
544 .files
545 .insert(trimmed_content.to_string());
546 existing_skill_contents
547 .bodies
548 .insert(trimmed_body.to_string());
549 Ok(())
550}
551
552#[derive(Default)]
553struct ExistingSkillContents {
554 files: HashSet<String>,
555 bodies: HashSet<String>,
556}
557
558async fn existing_skill_contents(fs: &dyn Fs, skills_dir: &Path) -> ExistingSkillContents {
559 let mut contents = ExistingSkillContents::default();
560 let Ok(mut entries) = fs.read_dir(skills_dir).await else {
561 return contents;
562 };
563 while let Some(entry) = entries.next().await {
564 let Ok(skill_dir) = entry else { continue };
565 let Ok(file_content) = fs.load(&skill_dir.join(SKILL_FILE_NAME)).await else {
566 continue;
567 };
568 contents.files.insert(file_content.trim().to_string());
569 let Ok((_metadata, body)) = parse_skill_file_content(&file_content) else {
570 continue;
571 };
572 contents.bodies.insert(body.trim().to_string());
573 }
574 contents
575}
576
577/// Build the SKILL.md file contents for a migrated rule.
578fn format_skill_file(name: &str, body: &str) -> String {
579 let mut output = format!(
580 "---\nname: {name}\ndescription: {PLACEHOLDER_DESCRIPTION}\n\
581 disable-model-invocation: true\n---\n"
582 );
583 let trimmed_body = body.trim();
584 if !trimmed_body.is_empty() {
585 output.push('\n');
586 output.push_str(trimmed_body);
587 output.push('\n');
588 }
589 output
590}
591
592/// Cap on how many suffixed variants we'll try before giving up. In
593/// practice nobody has more than a handful of rules with the same slug;
594/// the cap exists purely to bound the worst case if a user has somehow
595/// filled `~/.agents/skills/` with thousands of `name-N` directories.
596const MAX_SLUG_SUFFIX: usize = 1000;
597
598async fn pick_available_skill_dir(
599 fs: &dyn Fs,
600 skills_dir: &Path,
601 slug: &str,
602) -> Result<(String, PathBuf)> {
603 let primary = skills_dir.join(slug);
604 if !fs.is_dir(&primary).await {
605 return Ok((slug.to_string(), primary));
606 }
607 for i in 2..=MAX_SLUG_SUFFIX {
608 let candidate_name = format!("{slug}-{i}");
609 let candidate_dir = skills_dir.join(&candidate_name);
610 if !fs.is_dir(&candidate_dir).await {
611 return Ok((candidate_name, candidate_dir));
612 }
613 }
614 anyhow::bail!(
615 "no free skill directory found under {} for slug {slug:?} \
616 after {MAX_SLUG_SUFFIX} attempts",
617 skills_dir.display()
618 );
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use agent_skills::{SkillSource, parse_skill_frontmatter};
625 use fs::FakeFs;
626 use gpui::TestAppContext;
627
628 async fn write_migrated_skill_for_test(
629 fs: &dyn Fs,
630 skills_dir: &Path,
631 slug: &str,
632 body: &str,
633 ) -> Result<()> {
634 let mut existing_skill_contents = existing_skill_contents(fs, skills_dir).await;
635 write_migrated_skill(fs, skills_dir, slug, body, &mut existing_skill_contents).await
636 }
637
638 #[test]
639 fn format_skill_file_includes_disable_model_invocation() {
640 let content = format_skill_file("my-rule", "Body text.");
641 assert!(content.contains("\nname: my-rule\n"));
642 assert!(content.contains(&format!("\ndescription: {PLACEHOLDER_DESCRIPTION}\n")));
643 assert!(content.contains("\ndisable-model-invocation: true\n"));
644 assert!(content.ends_with("Body text.\n"));
645 }
646
647 #[test]
648 fn format_skill_file_handles_empty_body() {
649 let content = format_skill_file("my-rule", " \n ");
650 // Even for an empty body, the closing `---` must be present.
651 assert!(content.contains("\n---\n"));
652 assert!(content.contains("disable-model-invocation: true"));
653 }
654
655 #[test]
656 fn format_skill_file_round_trips_through_parser() {
657 let content = format_skill_file("my-rule", "Hello world.");
658 let skill = parse_skill_frontmatter(
659 Path::new("/skills/my-rule/SKILL.md"),
660 &content,
661 SkillSource::Global,
662 )
663 .expect("migrated SKILL.md should parse");
664 assert_eq!(skill.name, "my-rule");
665 assert_eq!(skill.description, PLACEHOLDER_DESCRIPTION);
666 assert!(skill.disable_model_invocation);
667 }
668
669 #[gpui::test]
670 async fn pick_available_skill_dir_returns_primary_when_unused(cx: &mut TestAppContext) {
671 let fs = FakeFs::new(cx.executor());
672 let skills_dir = PathBuf::from("/skills");
673 fs.create_dir(&skills_dir).await.unwrap();
674
675 let (name, dir) = pick_available_skill_dir(fs.as_ref(), &skills_dir, "my-rule")
676 .await
677 .unwrap();
678 assert_eq!(name, "my-rule");
679 assert_eq!(dir, skills_dir.join("my-rule"));
680 }
681
682 #[gpui::test]
683 async fn pick_available_skill_dir_appends_suffix_on_collision(cx: &mut TestAppContext) {
684 let fs = FakeFs::new(cx.executor());
685 let skills_dir = PathBuf::from("/skills");
686 fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
687 fs.create_dir(&skills_dir.join("my-rule-2")).await.unwrap();
688
689 let (name, dir) = pick_available_skill_dir(fs.as_ref(), &skills_dir, "my-rule")
690 .await
691 .unwrap();
692 assert_eq!(name, "my-rule-3");
693 assert_eq!(dir, skills_dir.join("my-rule-3"));
694 }
695
696 #[gpui::test]
697 async fn write_migrated_skill_creates_directory_and_file(cx: &mut TestAppContext) {
698 let fs = FakeFs::new(cx.executor());
699 let skills_dir = PathBuf::from("/skills");
700 fs.create_dir(&skills_dir).await.unwrap();
701
702 write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.")
703 .await
704 .unwrap();
705
706 let written = fs
707 .load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
708 .await
709 .expect("SKILL.md should exist");
710 let skill = parse_skill_frontmatter(
711 &skills_dir.join("my-rule").join(SKILL_FILE_NAME),
712 &written,
713 SkillSource::Global,
714 )
715 .expect("written SKILL.md should parse");
716 assert_eq!(skill.name, "my-rule");
717 assert!(skill.disable_model_invocation);
718 }
719
720 #[gpui::test]
721 async fn write_migrated_skill_skips_when_primary_content_is_identical(cx: &mut TestAppContext) {
722 let fs = FakeFs::new(cx.executor());
723 let skills_dir = PathBuf::from("/skills");
724 fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
725 // Seed the primary location with byte-identical content to what the
726 // migration would write.
727 let identical = format_skill_file("my-rule", "Body.");
728 fs.insert_file(
729 &skills_dir.join("my-rule").join(SKILL_FILE_NAME),
730 identical.as_bytes().to_vec(),
731 )
732 .await;
733
734 write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.")
735 .await
736 .unwrap();
737
738 // No `-2` duplicate should have been produced.
739 assert!(!fs.is_dir(&skills_dir.join("my-rule-2")).await);
740 // Primary still has the same content.
741 let primary = fs
742 .load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
743 .await
744 .unwrap();
745 assert_eq!(primary, identical);
746 }
747
748 #[gpui::test]
749 async fn write_migrated_skill_skips_when_primary_differs_only_in_whitespace(
750 cx: &mut TestAppContext,
751 ) {
752 let fs = FakeFs::new(cx.executor());
753 let skills_dir = PathBuf::from("/skills");
754 fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
755 // Same logical content but with extra leading/trailing whitespace
756 // (which is meaningless inside a SKILL.md).
757 let mut padded = String::from("\n\n");
758 padded.push_str(format_skill_file("my-rule", "Body.").trim());
759 padded.push_str("\n\n");
760 fs.insert_file(
761 &skills_dir.join("my-rule").join(SKILL_FILE_NAME),
762 padded.as_bytes().to_vec(),
763 )
764 .await;
765
766 write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Body.")
767 .await
768 .unwrap();
769
770 // No `-2` duplicate.
771 assert!(!fs.is_dir(&skills_dir.join("my-rule-2")).await);
772 // Primary content was NOT overwritten — the user's whitespace is
773 // preserved verbatim.
774 let primary = fs
775 .load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
776 .await
777 .unwrap();
778 assert_eq!(primary, padded);
779 }
780
781 #[gpui::test]
782 async fn write_migrated_skill_does_not_clobber_existing_skill(cx: &mut TestAppContext) {
783 let fs = FakeFs::new(cx.executor());
784 let skills_dir = PathBuf::from("/skills");
785 fs.create_dir(&skills_dir.join("my-rule")).await.unwrap();
786 fs.insert_file(
787 &skills_dir.join("my-rule").join(SKILL_FILE_NAME),
788 b"---\nname: my-rule\ndescription: pre-existing\n---\n\nDo not touch.\n".to_vec(),
789 )
790 .await;
791
792 write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.")
793 .await
794 .unwrap();
795
796 let pre_existing = fs
797 .load(&skills_dir.join("my-rule").join(SKILL_FILE_NAME))
798 .await
799 .unwrap();
800 assert!(pre_existing.contains("Do not touch."));
801
802 let migrated = fs
803 .load(&skills_dir.join("my-rule-2").join(SKILL_FILE_NAME))
804 .await
805 .expect("migrated SKILL.md should have landed at the suffixed path");
806 assert!(migrated.contains("Migrated body."));
807 assert!(migrated.contains("disable-model-invocation: true"));
808 }
809
810 #[gpui::test]
811 async fn write_migrated_skill_skips_when_any_existing_skill_has_same_body(
812 cx: &mut TestAppContext,
813 ) {
814 let fs = FakeFs::new(cx.executor());
815 let skills_dir = PathBuf::from("/skills");
816 fs.create_dir(&skills_dir.join("unrelated-skill"))
817 .await
818 .unwrap();
819 let existing = format_skill_file("unrelated-skill", "Migrated body.");
820 fs.insert_file(
821 &skills_dir.join("unrelated-skill").join(SKILL_FILE_NAME),
822 existing.as_bytes().to_vec(),
823 )
824 .await;
825
826 write_migrated_skill_for_test(fs.as_ref(), &skills_dir, "my-rule", "Migrated body.")
827 .await
828 .unwrap();
829
830 assert!(!fs.is_dir(&skills_dir.join("my-rule")).await);
831 let unrelated = fs
832 .load(&skills_dir.join("unrelated-skill").join(SKILL_FILE_NAME))
833 .await
834 .unwrap();
835 assert_eq!(unrelated, existing);
836 }
837
838 #[test]
839 fn format_default_rules_section_renders_headings_and_bodies() {
840 let rules = vec![
841 (
842 "My First Rule".to_string(),
843 "Body of first rule.".to_string(),
844 ),
845 (
846 "Second Rule".to_string(),
847 "Body of second rule.".to_string(),
848 ),
849 ];
850 let section = format_default_rules_section(&rules);
851 let expected = "## My First Rule\n\nBody of first rule.\n\n\
852 ## Second Rule\n\nBody of second rule.";
853 assert_eq!(section, expected);
854 }
855
856 #[test]
857 fn format_default_rules_section_trims_individual_bodies() {
858 // Leading and trailing whitespace on each body is trimmed, so we
859 // don't end up with weird gaps between sections.
860 let rules = vec![(
861 "Whitespace Rule".to_string(),
862 "\n\n Body with surrounding whitespace. \n\n".to_string(),
863 )];
864 let section = format_default_rules_section(&rules);
865 assert_eq!(
866 section,
867 "## Whitespace Rule\n\nBody with surrounding whitespace."
868 );
869 }
870
871 #[test]
872 fn format_default_rules_section_handles_empty_input() {
873 assert_eq!(format_default_rules_section(&[]), "");
874 }
875
876 #[test]
877 fn is_customized_builtin_body_returns_false_for_exact_default() {
878 let default = BuiltInPrompt::CommitMessage.default_content();
879 assert!(!is_customized_builtin_body(
880 BuiltInPrompt::CommitMessage,
881 default,
882 ));
883 }
884
885 #[test]
886 fn is_customized_builtin_body_ignores_surrounding_whitespace() {
887 // Trailing/leading whitespace doesn't count as a real edit.
888 let default = BuiltInPrompt::CommitMessage.default_content();
889 let padded = format!("\n\n {} \n\n", default.trim());
890 assert!(!is_customized_builtin_body(
891 BuiltInPrompt::CommitMessage,
892 &padded,
893 ));
894 }
895
896 #[test]
897 fn is_customized_builtin_body_returns_true_for_real_edit() {
898 let mut edited = BuiltInPrompt::CommitMessage.default_content().to_string();
899 edited.push_str("\n\nAlways mention the ticket number.");
900 assert!(is_customized_builtin_body(
901 BuiltInPrompt::CommitMessage,
902 &edited,
903 ));
904 }
905
906 #[test]
907 fn is_customized_builtin_body_returns_true_for_completely_different_body() {
908 assert!(is_customized_builtin_body(
909 BuiltInPrompt::CommitMessage,
910 "Use emoji and rhyming couplets.",
911 ));
912 }
913
914 #[gpui::test]
915 async fn append_default_rules_creates_agents_md_when_missing(cx: &mut TestAppContext) {
916 let fs = FakeFs::new(cx.executor());
917 let agents_md = PathBuf::from("/config/AGENTS.md");
918 // Don't pre-create the file or its parent dir; `fs.write` should
919 // create both.
920 let rules = vec![("Rule One".to_string(), "Body one.".to_string())];
921
922 append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
923 .await
924 .unwrap();
925
926 let contents = fs.load(&agents_md).await.unwrap();
927 assert_eq!(contents, "## Rule One\n\nBody one.\n");
928 }
929
930 #[gpui::test]
931 async fn append_default_rules_appends_to_existing_agents_md(cx: &mut TestAppContext) {
932 let fs = FakeFs::new(cx.executor());
933 let agents_md = PathBuf::from("/config/AGENTS.md");
934 fs.create_dir(agents_md.parent().unwrap()).await.unwrap();
935 fs.insert_file(
936 &agents_md,
937 b"# Top-level Agents Doc\n\nPre-existing user content.\n".to_vec(),
938 )
939 .await;
940
941 let rules = vec![
942 ("Rule One".to_string(), "Body one.".to_string()),
943 ("Rule Two".to_string(), "Body two.".to_string()),
944 ];
945 append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
946 .await
947 .unwrap();
948
949 let contents = fs.load(&agents_md).await.unwrap();
950 // Existing content is preserved (verbatim, just trimmed of
951 // trailing whitespace), followed by a blank-line separator and
952 // the appended migrated section.
953 assert!(contents.starts_with("# Top-level Agents Doc\n\nPre-existing user content."));
954 assert!(contents.contains("\n\n## Rule One\n\nBody one."));
955 assert!(contents.contains("\n\n## Rule Two\n\nBody two.\n"));
956 }
957
958 #[gpui::test]
959 async fn append_default_rules_skips_sections_already_in_agents_md(cx: &mut TestAppContext) {
960 let fs = FakeFs::new(cx.executor());
961 let agents_md = PathBuf::from("/config/AGENTS.md");
962 fs.create_dir(agents_md.parent().unwrap()).await.unwrap();
963 fs.insert_file(
964 &agents_md,
965 b"## Rule One\n\nBody one.\n\nPre-existing footer.\n".to_vec(),
966 )
967 .await;
968
969 let rules = vec![
970 ("Rule One".to_string(), "Body one.".to_string()),
971 ("Rule Two".to_string(), "Body two.".to_string()),
972 ];
973 let appended_indices = append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
974 .await
975 .unwrap();
976 assert_eq!(appended_indices, vec![1]);
977
978 let contents = fs.load(&agents_md).await.unwrap();
979 assert_eq!(contents.matches("## Rule One").count(), 1);
980 assert_eq!(contents.matches("## Rule Two").count(), 1);
981 }
982
983 #[gpui::test]
984 async fn append_default_rules_is_idempotent(cx: &mut TestAppContext) {
985 let fs = FakeFs::new(cx.executor());
986 let agents_md = PathBuf::from("/config/AGENTS.md");
987 let rules = vec![
988 ("Rule One".to_string(), "Body one.".to_string()),
989 ("Rule Two".to_string(), "Body two.".to_string()),
990 ];
991
992 let first_append = append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
993 .await
994 .unwrap();
995 let second_append = append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
996 .await
997 .unwrap();
998
999 assert_eq!(first_append, vec![0, 1]);
1000 assert!(second_append.is_empty());
1001
1002 let contents = fs.load(&agents_md).await.unwrap();
1003 assert_eq!(contents.matches("## Rule One").count(), 1);
1004 assert_eq!(contents.matches("## Rule Two").count(), 1);
1005 }
1006
1007 #[gpui::test]
1008 async fn append_default_rules_treats_whitespace_only_file_as_empty(cx: &mut TestAppContext) {
1009 let fs = FakeFs::new(cx.executor());
1010 let agents_md = PathBuf::from("/config/AGENTS.md");
1011 fs.create_dir(agents_md.parent().unwrap()).await.unwrap();
1012 fs.insert_file(&agents_md, b" \n\n \n".to_vec()).await;
1013
1014 let rules = vec![("Rule One".to_string(), "Body one.".to_string())];
1015 append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &rules)
1016 .await
1017 .unwrap();
1018
1019 // Existing whitespace is discarded; the result is just the
1020 // migrated section as if the file had been missing.
1021 let contents = fs.load(&agents_md).await.unwrap();
1022 assert_eq!(contents, "## Rule One\n\nBody one.\n");
1023 }
1024
1025 #[gpui::test]
1026 async fn append_default_rules_no_op_for_empty_rules(cx: &mut TestAppContext) {
1027 let fs = FakeFs::new(cx.executor());
1028 let agents_md = PathBuf::from("/config/AGENTS.md");
1029
1030 append_default_rules_to_agents_md(fs.as_ref(), &agents_md, &[])
1031 .await
1032 .unwrap();
1033
1034 // The file should not have been created.
1035 assert!(!fs.is_file(&agents_md).await);
1036 }
1037}
1038