1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_REF: &str = "main";
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum Scope {
13 Global,
14 Local,
15}
16
17impl Scope {
18 pub const ALL: &[Scope] = &[Scope::Global, Scope::Local];
19
20 #[must_use]
29 pub fn parse(s: &str) -> Option<Self> {
30 match s {
31 "global" => Some(Scope::Global),
32 "local" => Some(Scope::Local),
33 _ => None,
34 }
35 }
36
37 #[must_use]
39 pub fn as_str(&self) -> &'static str {
40 match self {
41 Scope::Global => "global",
42 Scope::Local => "local",
43 }
44 }
45}
46
47impl fmt::Display for Scope {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.write_str(self.as_str())
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum EntityType {
60 Skill,
61 Agent,
62}
63
64impl EntityType {
65 pub const ALL: &[EntityType] = &[EntityType::Agent, EntityType::Skill];
66
67 #[must_use]
76 pub fn parse(s: &str) -> Option<Self> {
77 match s {
78 "skill" => Some(EntityType::Skill),
79 "agent" => Some(EntityType::Agent),
80 _ => None,
81 }
82 }
83
84 #[must_use]
86 pub fn as_str(&self) -> &'static str {
87 match self {
88 EntityType::Skill => "skill",
89 EntityType::Agent => "agent",
90 }
91 }
92
93 #[must_use]
95 pub fn dir_name(&self) -> &'static str {
96 match self {
97 EntityType::Skill => "skills",
98 EntityType::Agent => "agents",
99 }
100 }
101}
102
103impl fmt::Display for EntityType {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 f.write_str(self.as_str())
106 }
107}
108
109#[must_use]
120pub fn short_sha(sha: &str) -> &str {
121 &sha[..sha.len().min(12)]
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum SourceFields {
134 Github {
135 owner_repo: String,
136 path_in_repo: String,
137 ref_: String,
138 },
139 Gitlab {
140 owner_repo: String,
141 path_in_repo: String,
142 ref_: String,
143 },
144 Local {
145 path: String,
146 },
147 Url {
148 url: String,
149 },
150}
151
152impl SourceFields {
153 #[must_use]
155 pub fn source_type(&self) -> &str {
156 match self {
157 SourceFields::Github { .. } => "github",
158 SourceFields::Gitlab { .. } => "gitlab",
159 SourceFields::Local { .. } => "local",
160 SourceFields::Url { .. } => "url",
161 }
162 }
163
164 #[must_use]
165 pub fn as_github(&self) -> Option<(&str, &str, &str)> {
166 match self {
167 SourceFields::Github {
168 owner_repo,
169 path_in_repo,
170 ref_,
171 } => Some((owner_repo, path_in_repo, ref_)),
172 _ => None,
173 }
174 }
175
176 #[must_use]
177 pub fn as_gitlab(&self) -> Option<(&str, &str, &str)> {
178 match self {
179 SourceFields::Gitlab {
180 owner_repo,
181 path_in_repo,
182 ref_,
183 } => Some((owner_repo, path_in_repo, ref_)),
184 _ => None,
185 }
186 }
187
188 #[must_use]
189 pub fn as_local(&self) -> Option<&str> {
190 match self {
191 SourceFields::Local { path } => Some(path),
192 _ => None,
193 }
194 }
195
196 #[must_use]
197 pub fn as_url(&self) -> Option<&str> {
198 match self {
199 SourceFields::Url { url } => Some(url),
200 _ => None,
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct Entry {
211 pub entity_type: EntityType,
212 pub name: String,
213 pub source: SourceFields,
214}
215
216impl Entry {
217 #[must_use]
218 pub fn source_type(&self) -> &str {
219 self.source.source_type()
220 }
221
222 #[cfg(test)]
226 pub fn owner_repo(&self) -> &str {
227 self.source.as_github().map_or("", |(or, _, _)| or)
228 }
229
230 #[cfg(test)]
231 pub fn path_in_repo(&self) -> &str {
232 self.source.as_github().map_or("", |(_, pir, _)| pir)
233 }
234
235 #[cfg(test)]
236 pub fn ref_(&self) -> &str {
237 self.source.as_github().map_or("", |(_, _, r)| r)
238 }
239
240 #[cfg(test)]
241 pub fn local_path(&self) -> &str {
242 self.source.as_local().unwrap_or("")
243 }
244
245 #[cfg(test)]
246 pub fn url(&self) -> &str {
247 self.source.as_url().unwrap_or("")
248 }
249}
250
251impl fmt::Display for Entry {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 write!(
254 f,
255 "{}/{}/{}",
256 self.source_type(),
257 self.entity_type,
258 self.name
259 )
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum InstallTarget {
270 Platform {
271 adapter: String,
272 scope: Scope,
273 },
274 Path {
275 tool_name: String,
276 entity_type: EntityType,
277 path: String,
278 },
279}
280
281impl InstallTarget {
282 #[must_use]
283 pub fn platform(adapter: impl Into<String>, scope: Scope) -> Self {
284 Self::Platform {
285 adapter: adapter.into(),
286 scope,
287 }
288 }
289
290 #[must_use]
291 pub fn path(
292 tool_name: impl Into<String>,
293 entity_type: EntityType,
294 path: impl Into<String>,
295 ) -> Self {
296 Self::Path {
297 tool_name: tool_name.into(),
298 entity_type,
299 path: path.into(),
300 }
301 }
302
303 #[must_use]
304 pub fn platform_name(&self) -> &str {
305 match self {
306 Self::Platform { adapter, .. } => adapter,
307 Self::Path { tool_name, .. } => tool_name,
308 }
309 }
310
311 #[must_use]
312 pub fn scope(&self) -> Option<Scope> {
313 match self {
314 Self::Platform { scope, .. } => Some(*scope),
315 Self::Path { .. } => None,
316 }
317 }
318
319 #[must_use]
320 pub fn entity_type(&self) -> Option<EntityType> {
321 match self {
322 Self::Platform { .. } => None,
323 Self::Path { entity_type, .. } => Some(*entity_type),
324 }
325 }
326
327 #[must_use]
328 pub fn path_str(&self) -> Option<&str> {
329 match self {
330 Self::Platform { .. } => None,
331 Self::Path { path, .. } => Some(path),
332 }
333 }
334
335 #[must_use]
336 pub fn manifest_line(&self) -> String {
337 match self {
338 Self::Platform { adapter, scope } => format!("install {adapter} {scope}"),
339 Self::Path {
340 tool_name,
341 entity_type,
342 path,
343 } => format!(
344 "install-path {} {entity_type} {}",
345 quote_manifest_field(tool_name),
346 quote_manifest_field(path)
347 ),
348 }
349 }
350}
351
352fn quote_manifest_field(value: &str) -> String {
353 match shlex::try_quote(value) {
354 Ok(quoted) => quoted.into_owned(),
355 Err(_) => value.to_string(),
356 }
357}
358
359impl fmt::Display for InstallTarget {
360 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361 match self {
362 Self::Platform { adapter, scope } => write!(f, "{adapter} ({scope})"),
363 Self::Path {
364 tool_name,
365 entity_type,
366 path,
367 } => write!(f, "{tool_name} {entity_type} ({path})"),
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct LockEntry {
378 pub sha: String,
379 pub raw_url: String,
380}
381
382#[derive(Debug, Clone, Default)]
387pub struct Manifest {
388 pub entries: Vec<Entry>,
389 pub install_targets: Vec<InstallTarget>,
390}
391
392#[derive(Debug, Clone)]
397pub struct InstallOptions {
398 pub dry_run: bool,
399 pub overwrite: bool,
400}
401
402impl Default for InstallOptions {
403 fn default() -> Self {
404 Self {
405 dry_run: false,
406 overwrite: true,
407 }
408 }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct ConflictState {
418 pub entry: String,
419 pub entity_type: EntityType,
420 pub old_sha: String,
421 pub new_sha: String,
422}
423
424#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn scope_parse_and_display() {
434 assert_eq!(Scope::parse("global"), Some(Scope::Global));
435 assert_eq!(Scope::parse("local"), Some(Scope::Local));
436 assert_eq!(Scope::parse("worldwide"), None);
437 assert_eq!(Scope::Global.to_string(), "global");
438 assert_eq!(Scope::Local.to_string(), "local");
439 assert_eq!(Scope::Global.as_str(), "global");
440 }
441
442 #[test]
443 fn scope_all_variants() {
444 assert_eq!(Scope::ALL.len(), 2);
445 assert!(Scope::ALL.contains(&Scope::Global));
446 assert!(Scope::ALL.contains(&Scope::Local));
447 }
448
449 #[test]
450 fn entity_type_parse_and_display() {
451 assert_eq!(EntityType::parse("skill"), Some(EntityType::Skill));
452 assert_eq!(EntityType::parse("agent"), Some(EntityType::Agent));
453 assert_eq!(EntityType::parse("hook"), None);
454 assert_eq!(EntityType::Skill.to_string(), "skill");
455 assert_eq!(EntityType::Agent.to_string(), "agent");
456 assert_eq!(EntityType::Skill.as_str(), "skill");
457 assert_eq!(EntityType::Agent.as_str(), "agent");
458 }
459
460 #[test]
461 fn entity_type_dir_name() {
462 assert_eq!(EntityType::Skill.dir_name(), "skills");
463 assert_eq!(EntityType::Agent.dir_name(), "agents");
464 }
465
466 #[test]
467 fn entity_type_all_variants() {
468 assert_eq!(EntityType::ALL.len(), 2);
469 assert!(EntityType::ALL.contains(&EntityType::Skill));
470 assert!(EntityType::ALL.contains(&EntityType::Agent));
471 }
472
473 #[test]
474 fn short_sha_truncates() {
475 let sha = "abcdef123456789012345678";
476 assert_eq!(short_sha(sha), "abcdef123456");
477 }
478
479 #[test]
480 fn short_sha_short_input() {
481 assert_eq!(short_sha("abc"), "abc");
482 }
483
484 #[test]
485 fn source_fields_typed_accessors() {
486 let gh = SourceFields::Github {
487 owner_repo: "o/r".into(),
488 path_in_repo: "a.md".into(),
489 ref_: "main".into(),
490 };
491 assert_eq!(gh.as_github(), Some(("o/r", "a.md", "main")));
492 assert_eq!(gh.as_local(), None);
493 assert_eq!(gh.as_url(), None);
494
495 let local = SourceFields::Local {
496 path: "test.md".into(),
497 };
498 assert_eq!(local.as_local(), Some("test.md"));
499 assert_eq!(local.as_github(), None);
500
501 let url = SourceFields::Url {
502 url: "https://x.com/s.md".into(),
503 };
504 assert_eq!(url.as_url(), Some("https://x.com/s.md"));
505 assert_eq!(url.as_github(), None);
506 }
507
508 #[test]
509 fn github_entry_source_type() {
510 let e = Entry {
511 entity_type: EntityType::Agent,
512 name: "test".into(),
513 source: SourceFields::Github {
514 owner_repo: "o/r".into(),
515 path_in_repo: "a.md".into(),
516 ref_: "main".into(),
517 },
518 };
519 assert_eq!(e.source_type(), "github");
520 assert_eq!(e.entity_type, EntityType::Agent);
521 assert_eq!(e.name, "test");
522 assert_eq!(e.owner_repo(), "o/r");
523 assert_eq!(e.path_in_repo(), "a.md");
524 assert_eq!(e.ref_(), "main");
525 assert_eq!(e.local_path(), "");
526 assert_eq!(e.url(), "");
527 }
528
529 #[test]
530 fn github_entry_fields() {
531 let e = Entry {
532 entity_type: EntityType::Skill,
533 name: "my-skill".into(),
534 source: SourceFields::Github {
535 owner_repo: "o/r".into(),
536 path_in_repo: "skills/s.md".into(),
537 ref_: "v1".into(),
538 },
539 };
540 assert_eq!(e.owner_repo(), "o/r");
541 assert_eq!(e.path_in_repo(), "skills/s.md");
542 assert_eq!(e.ref_(), "v1");
543 }
544
545 #[test]
546 fn local_entry_fields() {
547 let e = Entry {
548 entity_type: EntityType::Skill,
549 name: "test".into(),
550 source: SourceFields::Local {
551 path: "test.md".into(),
552 },
553 };
554 assert_eq!(e.source_type(), "local");
555 assert_eq!(e.local_path(), "test.md");
556 assert_eq!(e.owner_repo(), "");
557 assert_eq!(e.url(), "");
558 }
559
560 #[test]
561 fn url_entry_fields() {
562 let e = Entry {
563 entity_type: EntityType::Skill,
564 name: "my-skill".into(),
565 source: SourceFields::Url {
566 url: "https://example.com/skill.md".into(),
567 },
568 };
569 assert_eq!(e.source_type(), "url");
570 assert_eq!(e.url(), "https://example.com/skill.md");
571 assert_eq!(e.owner_repo(), "");
572 }
573
574 #[test]
575 fn entry_display() {
576 let e = Entry {
577 entity_type: EntityType::Agent,
578 name: "test".into(),
579 source: SourceFields::Github {
580 owner_repo: "o/r".into(),
581 path_in_repo: "a.md".into(),
582 ref_: "main".into(),
583 },
584 };
585 assert_eq!(e.to_string(), "github/agent/test");
586 }
587
588 #[test]
589 fn lock_entry() {
590 let le = LockEntry {
591 sha: "abc123".into(),
592 raw_url: "https://example.com".into(),
593 };
594 assert_eq!(le.sha, "abc123");
595 assert_eq!(le.raw_url, "https://example.com");
596 }
597
598 #[test]
599 fn install_target_with_scope_enum() {
600 let t = InstallTarget::platform("claude-code", Scope::Global);
601 assert_eq!(t.platform_name(), "claude-code");
602 assert_eq!(t.scope(), Some(Scope::Global));
603 assert_eq!(t.to_string(), "claude-code (global)");
604 }
605
606 #[test]
607 fn install_path_target_fields() {
608 let t = InstallTarget::path("openclaw", EntityType::Skill, "~/.openclaw/skills");
609 assert_eq!(t.platform_name(), "openclaw");
610 assert_eq!(t.entity_type(), Some(EntityType::Skill));
611 assert_eq!(t.path_str(), Some("~/.openclaw/skills"));
612 assert_eq!(t.to_string(), "openclaw skill (~/.openclaw/skills)");
613 }
614
615 #[test]
616 fn install_path_manifest_line_quotes_path_when_needed() {
617 let t = InstallTarget::path("openclaw", EntityType::Skill, "./custom skills");
618 assert_eq!(
619 t.manifest_line(),
620 "install-path openclaw skill './custom skills'"
621 );
622 }
623
624 #[test]
625 fn install_path_manifest_line_quotes_tool_name_when_needed() {
626 let t = InstallTarget::path("my tool", EntityType::Skill, "./out");
627 assert_eq!(t.manifest_line(), "install-path 'my tool' skill ./out");
628 }
629
630 #[test]
631 fn install_path_manifest_line_quotes_hash_path() {
632 let t = InstallTarget::path("openclaw", EntityType::Skill, "./skills#custom");
633 assert_eq!(
634 t.manifest_line(),
635 "install-path openclaw skill './skills#custom'"
636 );
637 }
638
639 #[test]
640 fn install_path_manifest_line_quotes_embedded_double_quote() {
641 let t = InstallTarget::path("openclaw", EntityType::Skill, "./custom \"skills\"");
642 assert_eq!(
643 t.manifest_line(),
644 "install-path openclaw skill './custom \"skills\"'"
645 );
646 }
647
648 #[test]
649 fn manifest_defaults() {
650 let m = Manifest::default();
651 assert!(m.entries.is_empty());
652 assert!(m.install_targets.is_empty());
653 }
654
655 #[test]
656 fn manifest_with_entries() {
657 let e = Entry {
658 entity_type: EntityType::Skill,
659 name: "test".into(),
660 source: SourceFields::Local {
661 path: "test.md".into(),
662 },
663 };
664 let t = InstallTarget::platform("claude-code", Scope::Local);
665 let m = Manifest {
666 entries: vec![e],
667 install_targets: vec![t],
668 };
669 assert_eq!(m.entries.len(), 1);
670 assert_eq!(m.install_targets.len(), 1);
671 }
672
673 #[test]
674 fn source_fields_gitlab_accessors() {
675 let gl = SourceFields::Gitlab {
676 owner_repo: "group/project".into(),
677 path_in_repo: "skills/my-skill.md".into(),
678 ref_: "main".into(),
679 };
680 assert_eq!(gl.source_type(), "gitlab");
681 assert_eq!(
682 gl.as_gitlab(),
683 Some(("group/project", "skills/my-skill.md", "main"))
684 );
685 assert_eq!(gl.as_github(), None);
686 assert_eq!(gl.as_local(), None);
687 assert_eq!(gl.as_url(), None);
688 }
689
690 #[test]
691 fn gitlab_entry_source_type() {
692 let e = Entry {
693 entity_type: EntityType::Agent,
694 name: "test".into(),
695 source: SourceFields::Gitlab {
696 owner_repo: "g/p".into(),
697 path_in_repo: "a.md".into(),
698 ref_: "main".into(),
699 },
700 };
701 assert_eq!(e.source_type(), "gitlab");
702 assert_eq!(e.to_string(), "gitlab/agent/test");
703 }
704
705 #[test]
706 fn conflict_state_equality() {
707 let a = ConflictState {
708 entry: "foo".into(),
709 entity_type: EntityType::Agent,
710 old_sha: "aaa".into(),
711 new_sha: "bbb".into(),
712 };
713 let b = a.clone();
714 assert_eq!(a, b);
715 }
716}