1use std::collections::BTreeMap;
14use std::error::Error;
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18use time::OffsetDateTime;
19
20pub const PACKAGES_SETTINGS_FILE: &str = "packages.json";
23
24pub const PACKAGE_MANIFEST_FILE: &str = "roder.toml";
26
27pub const PACKAGE_NPM_KEYWORD: &str = "roder-package";
29
30pub const EVENT_PACKAGE_INSTALLED: &str = "package.installed";
31pub const EVENT_PACKAGE_UPDATED: &str = "package.updated";
32pub const EVENT_PACKAGE_REMOVED: &str = "package.removed";
33pub const EVENT_PACKAGE_RESOURCE_TOGGLED: &str = "package.resource_toggled";
34pub const EVENT_PACKAGE_EXTENSIONS_APPROVED: &str = "package.extensions_approved";
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
39#[serde(tag = "kind", rename_all = "camelCase")]
40pub enum PackageSource {
41 Npm {
42 name: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 version: Option<String>,
45 },
46 Git {
47 url: String,
48 #[serde(rename = "refName")]
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 ref_name: Option<String>,
51 },
52 LocalPath {
53 path: String,
54 },
55}
56
57impl PackageSource {
58 pub fn spec(&self) -> String {
60 match self {
61 PackageSource::Npm { name, version } => match version {
62 Some(version) => format!("npm:{name}@{version}"),
63 None => format!("npm:{name}"),
64 },
65 PackageSource::Git { url, ref_name } => match ref_name {
66 Some(ref_name) => format!("git:{url}@{ref_name}"),
67 None => format!("git:{url}"),
68 },
69 PackageSource::LocalPath { path } => path.clone(),
70 }
71 }
72
73 pub fn identity(&self) -> PackageIdentity {
77 match self {
78 PackageSource::Npm { name, .. } => PackageIdentity(format!("npm:{name}")),
79 PackageSource::Git { url, .. } => {
80 PackageIdentity(format!("git:{}", normalize_git_identity(url)))
81 }
82 PackageSource::LocalPath { path } => PackageIdentity(format!("path:{path}")),
83 }
84 }
85
86 pub fn pinned(&self) -> bool {
88 match self {
89 PackageSource::Npm { version, .. } => version.is_some(),
90 PackageSource::Git { ref_name, .. } => ref_name.is_some(),
91 PackageSource::LocalPath { .. } => false,
92 }
93 }
94}
95
96impl fmt::Display for PackageSource {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str(&self.spec())
99 }
100}
101
102fn normalize_git_identity(url: &str) -> String {
103 let trimmed = url.trim_end_matches('/');
104 trimmed
105 .strip_suffix(".git")
106 .unwrap_or(trimmed)
107 .to_ascii_lowercase()
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
112pub struct PackageIdentity(pub String);
113
114impl fmt::Display for PackageIdentity {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(&self.0)
117 }
118}
119
120#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
121#[serde(rename_all = "camelCase")]
122pub enum PackageScope {
123 User,
124 Project,
125}
126
127impl fmt::Display for PackageScope {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 PackageScope::User => f.write_str("user"),
131 PackageScope::Project => f.write_str("project"),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[serde(rename_all = "camelCase")]
139pub struct PackageRecord {
140 pub package_id: String,
142 pub identity: PackageIdentity,
143 pub source: PackageSource,
144 pub scope: PackageScope,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub install_path: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub resolved: Option<String>,
152 #[serde(default = "default_true")]
153 pub enabled: bool,
154 #[serde(default)]
156 pub allow_scripts: bool,
157 #[serde(default)]
160 pub extensions_approved: bool,
161 #[serde(with = "time::serde::rfc3339")]
162 pub installed_at: OffsetDateTime,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub content_hash: Option<String>,
165 #[serde(default, skip_serializing_if = "PackageResourceFilters::is_empty")]
166 pub filters: PackageResourceFilters,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub disabled_resources: Vec<String>,
170}
171
172fn default_true() -> bool {
173 true
174}
175
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
177#[serde(rename_all = "camelCase")]
178pub enum PackageResourceKind {
179 Extension,
180 Skill,
181 Command,
182 Theme,
183}
184
185impl PackageResourceKind {
186 pub fn as_str(&self) -> &'static str {
187 match self {
188 PackageResourceKind::Extension => "extension",
189 PackageResourceKind::Skill => "skill",
190 PackageResourceKind::Command => "command",
191 PackageResourceKind::Theme => "theme",
192 }
193 }
194}
195
196impl fmt::Display for PackageResourceKind {
197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 f.write_str(self.as_str())
199 }
200}
201
202impl std::str::FromStr for PackageResourceKind {
203 type Err = PackageError;
204
205 fn from_str(value: &str) -> Result<Self, Self::Err> {
206 match value.trim().to_ascii_lowercase().as_str() {
207 "extension" | "extensions" => Ok(Self::Extension),
208 "skill" | "skills" => Ok(Self::Skill),
209 "command" | "commands" => Ok(Self::Command),
210 "theme" | "themes" => Ok(Self::Theme),
211 other => Err(PackageError::InvalidResourceKind {
212 kind: other.to_string(),
213 }),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "camelCase")]
221pub struct PackageResource {
222 pub package_id: String,
223 pub kind: PackageResourceKind,
224 pub path: String,
226 pub name: String,
228 pub enabled: bool,
229 pub requires_approval: bool,
231}
232
233impl PackageResource {
234 pub fn id(&self) -> String {
236 package_resource_id(&self.package_id, self.kind, &self.name)
237 }
238}
239
240pub fn package_resource_id(package_id: &str, kind: PackageResourceKind, name: &str) -> String {
241 format!("{package_id}:{kind}/{name}")
242}
243
244pub fn parse_package_resource_id(
246 id: &str,
247) -> Result<(String, PackageResourceKind, String), PackageError> {
248 let (package_id, rest) = id.split_once(':').ok_or_else(|| invalid_resource_id(id))?;
249 let (kind, name) = rest
250 .split_once('/')
251 .ok_or_else(|| invalid_resource_id(id))?;
252 if package_id.is_empty() || name.is_empty() {
253 return Err(invalid_resource_id(id));
254 }
255 Ok((package_id.to_string(), kind.parse()?, name.to_string()))
256}
257
258fn invalid_resource_id(id: &str) -> PackageError {
259 PackageError::InvalidResourceId { id: id.to_string() }
260}
261
262#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "camelCase")]
266pub struct PackageManifestSpec {
267 pub id: String,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub name: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub version: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub description: Option<String>,
274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
276 pub extensions: Vec<String>,
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
278 pub skills: Vec<String>,
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub commands: Vec<String>,
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
282 pub themes: Vec<String>,
283}
284
285#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
295#[serde(rename_all = "camelCase")]
296pub struct PackageResourceFilters {
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub extensions: Option<Vec<String>>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub skills: Option<Vec<String>>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub commands: Option<Vec<String>>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub themes: Option<Vec<String>>,
305}
306
307impl PackageResourceFilters {
308 pub fn is_empty(&self) -> bool {
309 self.extensions.is_none()
310 && self.skills.is_none()
311 && self.commands.is_none()
312 && self.themes.is_none()
313 }
314
315 pub fn for_kind(&self, kind: PackageResourceKind) -> Option<&[String]> {
316 match kind {
317 PackageResourceKind::Extension => self.extensions.as_deref(),
318 PackageResourceKind::Skill => self.skills.as_deref(),
319 PackageResourceKind::Command => self.commands.as_deref(),
320 PackageResourceKind::Theme => self.themes.as_deref(),
321 }
322 }
323
324 pub fn set_for_kind(&mut self, kind: PackageResourceKind, patterns: Option<Vec<String>>) {
325 match kind {
326 PackageResourceKind::Extension => self.extensions = patterns,
327 PackageResourceKind::Skill => self.skills = patterns,
328 PackageResourceKind::Command => self.commands = patterns,
329 PackageResourceKind::Theme => self.themes = patterns,
330 }
331 }
332
333 pub fn allows(&self, kind: PackageResourceKind, path: &str) -> bool {
336 filter_allows(self.for_kind(kind), path)
337 }
338}
339
340fn filter_allows(patterns: Option<&[String]>, path: &str) -> bool {
341 let Some(patterns) = patterns else {
342 return true;
343 };
344 let mut includes = Vec::new();
345 let mut excludes = Vec::new();
346 for pattern in patterns {
347 if let Some(exact) = pattern.strip_prefix('-') {
348 if exact == path {
349 return false;
350 }
351 } else if let Some(exact) = pattern.strip_prefix('+') {
352 if exact == path {
353 return true;
354 }
355 } else if let Some(glob) = pattern.strip_prefix('!') {
356 excludes.push(glob);
357 } else {
358 includes.push(pattern.as_str());
359 }
360 }
361 if excludes.iter().any(|glob| glob_match(glob, path)) {
362 return false;
363 }
364 if includes.is_empty() {
365 return false;
367 }
368 includes.iter().any(|glob| glob_match(glob, path))
369}
370
371pub fn glob_match(pattern: &str, path: &str) -> bool {
375 let pattern_segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
376 let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
377 segments_match(&pattern_segments, &path_segments)
378}
379
380fn segments_match(pattern: &[&str], path: &[&str]) -> bool {
381 match pattern.first() {
382 None => true, Some(&"**") => {
384 if segments_match(&pattern[1..], path) {
385 return true;
386 }
387 if path.is_empty() {
388 return false;
389 }
390 segments_match(pattern, &path[1..])
391 }
392 Some(first) => {
393 let Some(segment) = path.first() else {
394 return false;
395 };
396 segment_match(first, segment) && segments_match(&pattern[1..], &path[1..])
397 }
398 }
399}
400
401fn segment_match(pattern: &str, segment: &str) -> bool {
402 let parts: Vec<&str> = pattern.split('*').collect();
403 if parts.len() == 1 {
404 return pattern == segment;
405 }
406 let mut rest = segment;
407 for (index, part) in parts.iter().enumerate() {
408 if index == 0 {
409 let Some(after) = rest.strip_prefix(part) else {
410 return false;
411 };
412 rest = after;
413 } else if index == parts.len() - 1 {
414 return rest.ends_with(part);
415 } else if part.is_empty() {
416 continue;
417 } else {
418 let Some(found) = rest.find(part) else {
419 return false;
420 };
421 rest = &rest[found + part.len()..];
422 }
423 }
424 true
425}
426
427pub fn parse_package_spec(input: &str) -> Result<PackageSource, PackageError> {
436 let input = input.trim();
437 if input.is_empty() {
438 return Err(PackageError::InvalidSpec {
439 spec: input.to_string(),
440 reason: "spec is empty".to_string(),
441 });
442 }
443 if let Some(rest) = input.strip_prefix("npm:") {
444 return parse_npm_spec(input, rest);
445 }
446 if input.starts_with("git://") {
447 return parse_git_spec(input, input);
448 }
449 if let Some(rest) = input.strip_prefix("git:") {
450 return parse_git_spec(input, rest);
451 }
452 if ["https://", "http://", "ssh://", "file://"]
453 .iter()
454 .any(|scheme| input.starts_with(scheme))
455 {
456 return parse_git_spec(input, input);
457 }
458 if input.starts_with('/')
459 || input.starts_with("./")
460 || input.starts_with("../")
461 || input.starts_with("~/")
462 || input == "."
463 || input == ".."
464 {
465 return Ok(PackageSource::LocalPath {
466 path: input.to_string(),
467 });
468 }
469 Err(PackageError::InvalidSpec {
470 spec: input.to_string(),
471 reason: "expected npm:<name>[@version], git:<url>[@ref], a protocol URL, or a local path"
472 .to_string(),
473 })
474}
475
476fn parse_npm_spec(original: &str, rest: &str) -> Result<PackageSource, PackageError> {
477 let invalid = |reason: &str| PackageError::InvalidSpec {
478 spec: original.to_string(),
479 reason: reason.to_string(),
480 };
481 if rest.is_empty() {
482 return Err(invalid("npm spec is missing a package name"));
483 }
484 let (name, version) = if let Some(scoped) = rest.strip_prefix('@') {
485 match scoped.split_once('@') {
486 Some((name, version)) => (format!("@{name}"), Some(version.to_string())),
487 None => (format!("@{scoped}"), None),
488 }
489 } else {
490 match rest.split_once('@') {
491 Some((name, version)) => (name.to_string(), Some(version.to_string())),
492 None => (rest.to_string(), None),
493 }
494 };
495 if name == "@" || name.is_empty() {
496 return Err(invalid("npm spec is missing a package name"));
497 }
498 if name.starts_with('@') && !name[1..].contains('/') {
499 return Err(invalid("scoped npm names must look like @scope/name"));
500 }
501 if let Some(version) = &version
502 && version.is_empty()
503 {
504 return Err(invalid("npm version after @ is empty"));
505 }
506 if name.contains("..") || name.contains(' ') {
507 return Err(invalid("npm package name contains invalid characters"));
508 }
509 Ok(PackageSource::Npm { name, version })
510}
511
512fn parse_git_spec(original: &str, rest: &str) -> Result<PackageSource, PackageError> {
513 let invalid = |reason: &str| PackageError::InvalidSpec {
514 spec: original.to_string(),
515 reason: reason.to_string(),
516 };
517 if rest.is_empty() {
518 return Err(invalid("git spec is missing a repository"));
519 }
520 let (location, ref_name) = split_git_ref(rest);
521 if location.is_empty() {
522 return Err(invalid("git spec is missing a repository"));
523 }
524 if let Some(ref_name) = &ref_name
525 && ref_name.is_empty()
526 {
527 return Err(invalid("git ref after @ is empty"));
528 }
529 let has_protocol = location.contains("://");
530 let is_scp_form = !has_protocol && location.contains('@') && location.contains(':');
531 let is_shorthand = !has_protocol && !is_scp_form && location.contains('/');
532 if !has_protocol && !is_scp_form && !is_shorthand {
533 return Err(invalid(
534 "git spec must be host/user/repo shorthand, user@host:path, or a protocol URL",
535 ));
536 }
537 let url = if is_shorthand {
538 format!("https://{location}")
539 } else {
540 location.to_string()
541 };
542 Ok(PackageSource::Git {
543 url,
544 ref_name: ref_name.map(str::to_string),
545 })
546}
547
548fn split_git_ref(input: &str) -> (&str, Option<&str>) {
552 let Some(at) = input.rfind('@') else {
553 return (input, None);
554 };
555 let last_slash = input.rfind('/');
556 let last_colon = input.rfind(':');
557 let boundary = last_slash.max(last_colon);
558 match boundary {
559 Some(boundary) if at > boundary => (&input[..at], Some(&input[at + 1..])),
560 _ => (input, None),
561 }
562}
563
564pub fn validate_package_id(id: &str) -> Result<(), PackageError> {
566 let valid = !id.is_empty()
567 && id.len() <= 100
568 && id
569 .chars()
570 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
571 && id.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
572 if valid {
573 Ok(())
574 } else {
575 Err(PackageError::InvalidPackageId { id: id.to_string() })
576 }
577}
578
579pub fn derive_package_id(source: &PackageSource) -> String {
582 let raw = match source {
583 PackageSource::Npm { name, .. } => name.rsplit('/').next().unwrap_or(name).to_string(),
584 PackageSource::Git { url, .. } => {
585 let trimmed = url.trim_end_matches('/');
586 let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
587 trimmed
588 .rsplit(['/', ':'])
589 .next()
590 .unwrap_or(trimmed)
591 .to_string()
592 }
593 PackageSource::LocalPath { path } => {
594 let trimmed = path.trim_end_matches('/');
595 trimmed
596 .rsplit(['/', '\\'])
597 .next()
598 .filter(|name| !name.is_empty())
599 .unwrap_or("package")
600 .to_string()
601 }
602 };
603 let mut id: String = raw
604 .to_ascii_lowercase()
605 .chars()
606 .map(|c| {
607 if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.') {
608 c
609 } else {
610 '-'
611 }
612 })
613 .collect();
614 while id.starts_with(['-', '_', '.']) {
615 id.remove(0);
616 }
617 if id.is_empty() {
618 id = "package".to_string();
619 }
620 id
621}
622
623#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
627#[serde(rename_all = "snake_case")]
628pub struct PackageExtensionLaunch {
629 pub command: String,
630 #[serde(default)]
631 pub args: Vec<String>,
632 #[serde(default)]
633 pub cwd: Option<String>,
634 #[serde(default)]
635 pub env: BTreeMap<String, String>,
636 #[serde(default)]
637 pub startup_timeout_ms: Option<u64>,
638 #[serde(default)]
639 pub event_filter_kinds: Vec<String>,
640}
641
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub enum PackageError {
644 InvalidSpec { spec: String, reason: String },
645 InvalidPackageId { id: String },
646 InvalidResourceKind { kind: String },
647 InvalidResourceId { id: String },
648 DuplicatePackage { identity: String, scope: String },
649 PackageNotFound { spec: String },
650}
651
652impl fmt::Display for PackageError {
653 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
654 match self {
655 PackageError::InvalidSpec { spec, reason } => {
656 write!(f, "invalid package spec {spec:?}: {reason}")
657 }
658 PackageError::InvalidPackageId { id } => write!(
659 f,
660 "invalid package id {id:?}: ids are lowercase alphanumeric plus '-', '_', '.'"
661 ),
662 PackageError::InvalidResourceKind { kind } => write!(
663 f,
664 "invalid resource kind {kind:?}: expected extension, skill, command, or theme"
665 ),
666 PackageError::InvalidResourceId { id } => write!(
667 f,
668 "invalid resource id {id:?}: expected <package-id>:<kind>/<name>"
669 ),
670 PackageError::DuplicatePackage { identity, scope } => {
671 write!(
672 f,
673 "package {identity} is already installed in {scope} scope"
674 )
675 }
676 PackageError::PackageNotFound { spec } => {
677 write!(f, "package {spec} is not installed")
678 }
679 }
680 }
681}
682
683impl Error for PackageError {}