返回 DeepSeek-TUI-2026
system.rs
根目录 / crates / tui / src / skills / system.rs
1 //! System-skill installer: bundles skill-creator and auto-installs it on first launch.
2
3 use std::fs;
4 use std::path::Path;
5
6 const BUNDLED_SKILL_VERSION: &str = "1";
7 const SKILL_CREATOR_BODY: &str = include_str!("../../assets/skills/skill-creator/SKILL.md");
8
9 /// Install bundled system skills into `skills_dir`.
10 ///
11 /// Behaviour:
12 /// - Fresh install (no marker, no dir): installs `skill-creator/SKILL.md` and writes
13 /// the version marker.
14 /// - Version bump (marker present with older version, dir present): re-installs.
15 /// - User deleted the dir while marker still present at same version: leaves it gone.
16 /// - Idempotent: calling twice with no changes is a no-op.
17 ///
18 /// Errors are I/O errors from the filesystem; the caller should log them but not
19 /// abort startup.
20 pub fn install_system_skills(skills_dir: &Path) -> std::io::Result<()> {
21 let marker = skills_dir.join(".system-installed-version");
22 let target_dir = skills_dir.join("skill-creator");
23 let target_file = target_dir.join("SKILL.md");
24
25 let installed_version = fs::read_to_string(&marker)
26 .ok()
27 .map(|s| s.trim().to_string());
28 let dir_exists = target_dir.exists();
29
30 // Re-install only when BOTH conditions hold:
31 // (a) bundled version is newer than what is recorded in the marker, AND
32 // (b) the skill directory still exists (user hasn't intentionally deleted it).
33 // Fresh install (no marker AND no dir) is also handled.
34 let should_install = match (installed_version.as_deref(), dir_exists) {
35 // Fresh install: neither marker nor directory.
36 (None, false) => true,
37 // Version bump: marker is outdated but directory still present.
38 (Some(v), true) if v != BUNDLED_SKILL_VERSION => true,
39 // Every other case: already installed at current version, or user deleted
40 // the dir (respect that choice).
41 _ => false,
42 };
43
44 if should_install {
45 fs::create_dir_all(skills_dir)?;
46 fs::create_dir_all(&target_dir)?;
47 fs::write(&target_file, SKILL_CREATOR_BODY)?;
48 fs::write(&marker, BUNDLED_SKILL_VERSION)?;
49 }
50 Ok(())
51 }
52
53 /// Remove the `skill-creator` system skill and its version marker.
54 ///
55 /// Intended for tests and `deepseek setup --clean`. Ignores missing files.
56 #[allow(dead_code)]
57 pub fn uninstall_system_skills(skills_dir: &Path) -> std::io::Result<()> {
58 let marker = skills_dir.join(".system-installed-version");
59 let target_dir = skills_dir.join("skill-creator");
60
61 if target_dir.exists() {
62 fs::remove_dir_all(&target_dir)?;
63 }
64 if marker.exists() {
65 fs::remove_file(&marker)?;
66 }
67 Ok(())
68 }
69
70 #[cfg(test)]
71 mod tests {
72 use super::*;
73 use tempfile::TempDir;
74
75 // ── helpers ──────────────────────────────────────────────────────────────
76
77 fn skill_file(tmp: &TempDir) -> std::path::PathBuf {
78 tmp.path().join("skill-creator").join("SKILL.md")
79 }
80
81 fn marker_file(tmp: &TempDir) -> std::path::PathBuf {
82 tmp.path().join(".system-installed-version")
83 }
84
85 // ── fresh install ─────────────────────────────────────────────────────────
86
87 #[test]
88 fn fresh_install_creates_skill_and_marker() {
89 let tmp = TempDir::new().unwrap();
90 install_system_skills(tmp.path()).unwrap();
91
92 assert!(skill_file(&tmp).exists(), "SKILL.md should be created");
93 assert!(marker_file(&tmp).exists(), "marker should be created");
94
95 let ver = fs::read_to_string(marker_file(&tmp)).unwrap();
96 assert_eq!(ver.trim(), BUNDLED_SKILL_VERSION);
97 }
98
99 // ── idempotence ───────────────────────────────────────────────────────────
100
101 #[test]
102 fn calling_twice_is_idempotent() {
103 let tmp = TempDir::new().unwrap();
104 install_system_skills(tmp.path()).unwrap();
105
106 // Overwrite SKILL.md with sentinel to detect an undesired second write.
107 fs::write(skill_file(&tmp), "sentinel").unwrap();
108
109 install_system_skills(tmp.path()).unwrap();
110
111 let contents = fs::read_to_string(skill_file(&tmp)).unwrap();
112 assert_eq!(
113 contents, "sentinel",
114 "second install should not overwrite SKILL.md when version is current"
115 );
116 }
117
118 // ── user deleted the directory ────────────────────────────────────────────
119
120 #[test]
121 fn user_deleted_dir_is_not_recreated() {
122 let tmp = TempDir::new().unwrap();
123 install_system_skills(tmp.path()).unwrap();
124
125 // Simulate user deliberately removing the skill directory.
126 fs::remove_dir_all(tmp.path().join("skill-creator")).unwrap();
127
128 // Re-launch must NOT recreate the directory.
129 install_system_skills(tmp.path()).unwrap();
130
131 assert!(
132 !skill_file(&tmp).exists(),
133 "skill-creator must not be recreated after user deleted it"
134 );
135 }
136
137 // ── version bump re-installs ──────────────────────────────────────────────
138
139 #[test]
140 fn outdated_marker_triggers_reinstall() {
141 let tmp = TempDir::new().unwrap();
142
143 // Simulate a previous install at a lower version.
144 let skill_dir = tmp.path().join("skill-creator");
145 fs::create_dir_all(&skill_dir).unwrap();
146 fs::write(skill_dir.join("SKILL.md"), "old content").unwrap();
147 fs::write(marker_file(&tmp), "0").unwrap(); // older than BUNDLED_SKILL_VERSION
148
149 install_system_skills(tmp.path()).unwrap();
150
151 let contents = fs::read_to_string(skill_file(&tmp)).unwrap();
152 assert_ne!(
153 contents, "old content",
154 "outdated skill should be overwritten on version bump"
155 );
156 assert_eq!(
157 contents, SKILL_CREATOR_BODY,
158 "re-installed file must match the bundled body"
159 );
160
161 let ver = fs::read_to_string(marker_file(&tmp)).unwrap();
162 assert_eq!(
163 ver.trim(),
164 BUNDLED_SKILL_VERSION,
165 "marker should be updated"
166 );
167 }
168
169 // ── uninstall ─────────────────────────────────────────────────────────────
170
171 #[test]
172 fn uninstall_removes_skill_and_marker() {
173 let tmp = TempDir::new().unwrap();
174 install_system_skills(tmp.path()).unwrap();
175 uninstall_system_skills(tmp.path()).unwrap();
176
177 assert!(!skill_file(&tmp).exists(), "SKILL.md should be removed");
178 assert!(!marker_file(&tmp).exists(), "marker should be removed");
179 }
180
181 #[test]
182 fn uninstall_on_clean_dir_is_a_noop() {
183 let tmp = TempDir::new().unwrap();
184 // Must not panic or error.
185 uninstall_system_skills(tmp.path()).unwrap();
186 }
187 }
188
188 lines RUST