Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:17:02.937Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

rules_to_skills_migration.rs

1038 lines · 39.1 KB · rust
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(&section);
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
Served at tenant.openagents/omega Member data and write actions are omitted.