@engramresearch/srun 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/model.rs DELETED
@@ -1,262 +0,0 @@
1
- use std::collections::{BTreeMap, BTreeSet};
2
- use std::fmt;
3
- use std::path::PathBuf;
4
-
5
- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
6
- pub enum Intent {
7
- Dev,
8
- Build,
9
- Installer,
10
- Test,
11
- Lint,
12
- Format,
13
- }
14
-
15
- impl Intent {
16
- pub const EXECUTABLE: [Intent; 6] = [
17
- Intent::Dev,
18
- Intent::Build,
19
- Intent::Installer,
20
- Intent::Test,
21
- Intent::Lint,
22
- Intent::Format,
23
- ];
24
-
25
- pub fn label(self) -> &'static str {
26
- match self {
27
- Intent::Dev => "dev",
28
- Intent::Build => "build",
29
- Intent::Installer => "installer",
30
- Intent::Test => "test",
31
- Intent::Lint => "lint",
32
- Intent::Format => "format",
33
- }
34
- }
35
- }
36
-
37
- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
38
- pub enum PackageManager {
39
- Pnpm,
40
- Bun,
41
- Yarn,
42
- Npm,
43
- }
44
-
45
- impl PackageManager {
46
- pub fn label(self) -> &'static str {
47
- match self {
48
- PackageManager::Pnpm => "pnpm",
49
- PackageManager::Bun => "bun",
50
- PackageManager::Yarn => "yarn",
51
- PackageManager::Npm => "npm",
52
- }
53
- }
54
-
55
- pub fn script_command(self, script: &str) -> CommandSpec {
56
- match self {
57
- PackageManager::Pnpm => CommandSpec::new("pnpm", ["run", script]),
58
- PackageManager::Bun => CommandSpec::new("bun", ["run", script]),
59
- PackageManager::Yarn => CommandSpec::new("yarn", [script]),
60
- PackageManager::Npm => CommandSpec::new("npm", ["run", script]),
61
- }
62
- }
63
- }
64
-
65
- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
66
- pub enum Framework {
67
- Electron,
68
- Tauri,
69
- Next,
70
- Vite,
71
- Turbo,
72
- Nx,
73
- }
74
-
75
- impl Framework {
76
- pub fn label(self) -> &'static str {
77
- match self {
78
- Framework::Electron => "Electron",
79
- Framework::Tauri => "Tauri",
80
- Framework::Next => "Next.js",
81
- Framework::Vite => "Vite",
82
- Framework::Turbo => "TurboRepo",
83
- Framework::Nx => "NX",
84
- }
85
- }
86
- }
87
-
88
- #[derive(Debug, Clone, Default)]
89
- pub struct PackageJson {
90
- pub scripts: BTreeMap<String, String>,
91
- pub dependencies: BTreeSet<String>,
92
- }
93
-
94
- #[derive(Debug, Clone)]
95
- pub struct ProjectInfo {
96
- pub root: PathBuf,
97
- pub package_manager: Option<PackageManager>,
98
- pub package_json: Option<PackageJson>,
99
- pub frameworks: BTreeSet<Framework>,
100
- pub has_cargo_toml: bool,
101
- pub has_package_json: bool,
102
- pub is_monorepo: bool,
103
- pub warnings: Vec<String>,
104
- pub traces: Vec<String>,
105
- }
106
-
107
- impl ProjectInfo {
108
- pub fn project_type(&self) -> String {
109
- if self.is_cargo_only() {
110
- return "Cargo".to_string();
111
- }
112
-
113
- let mut labels: Vec<&str> = self
114
- .frameworks
115
- .iter()
116
- .map(|framework| framework.label())
117
- .collect();
118
- if self.is_monorepo {
119
- labels.push("Monorepo");
120
- }
121
-
122
- if labels.is_empty() {
123
- if self.has_package_json {
124
- "Node.js".to_string()
125
- } else {
126
- "Unknown".to_string()
127
- }
128
- } else {
129
- labels.join(" + ")
130
- }
131
- }
132
-
133
- pub fn scripts(&self) -> BTreeMap<String, String> {
134
- self.package_json
135
- .as_ref()
136
- .map(|package_json| package_json.scripts.clone())
137
- .unwrap_or_default()
138
- }
139
-
140
- pub fn has_framework(&self, framework: Framework) -> bool {
141
- self.frameworks.contains(&framework)
142
- }
143
-
144
- pub fn is_cargo_only(&self) -> bool {
145
- self.has_cargo_toml && !self.has_package_json
146
- }
147
- }
148
-
149
- #[derive(Debug, Clone, PartialEq, Eq)]
150
- pub struct CommandSpec {
151
- pub program: String,
152
- pub args: Vec<String>,
153
- }
154
-
155
- impl CommandSpec {
156
- pub fn new<I, S>(program: impl Into<String>, args: I) -> Self
157
- where
158
- I: IntoIterator<Item = S>,
159
- S: Into<String>,
160
- {
161
- Self {
162
- program: program.into(),
163
- args: args.into_iter().map(Into::into).collect(),
164
- }
165
- }
166
-
167
- pub fn display(&self) -> String {
168
- if self.args.is_empty() {
169
- self.program.clone()
170
- } else {
171
- format!("{} {}", self.program, self.args.join(" "))
172
- }
173
- }
174
- }
175
-
176
- #[derive(Debug, Clone, PartialEq, Eq)]
177
- pub struct ResolvedCommand {
178
- pub intent: Intent,
179
- pub command: CommandSpec,
180
- pub reason: String,
181
- pub script: Option<String>,
182
- }
183
-
184
- #[derive(Debug, Clone, PartialEq, Eq)]
185
- pub enum ResolveFailure {
186
- NoProjectDetected,
187
- NoPackageManager,
188
- NoMatchingScript { candidates: Vec<String> },
189
- UnsupportedIntent,
190
- }
191
-
192
- #[derive(Debug)]
193
- pub enum SrunError {
194
- Io {
195
- path: PathBuf,
196
- source: std::io::Error,
197
- },
198
- Json {
199
- path: PathBuf,
200
- source: serde_json::Error,
201
- },
202
- Resolve {
203
- intent: Intent,
204
- failure: ResolveFailure,
205
- },
206
- ProcessSpawn {
207
- command: String,
208
- source: std::io::Error,
209
- },
210
- }
211
-
212
- impl fmt::Display for SrunError {
213
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214
- match self {
215
- SrunError::Io { path, source } => {
216
- write!(formatter, "failed to read {}: {}", path.display(), source)
217
- }
218
- SrunError::Json { path, source } => {
219
- write!(formatter, "failed to parse {}: {}", path.display(), source)
220
- }
221
- SrunError::Resolve { intent, failure } => write!(
222
- formatter,
223
- "could not resolve {}: {}",
224
- intent.label(),
225
- failure
226
- ),
227
- SrunError::ProcessSpawn { command, source } => {
228
- write!(formatter, "failed to execute `{}`: {}", command, source)
229
- }
230
- }
231
- }
232
- }
233
-
234
- impl std::error::Error for SrunError {}
235
-
236
- impl fmt::Display for ResolveFailure {
237
- fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238
- match self {
239
- ResolveFailure::NoProjectDetected => {
240
- write!(formatter, "no supported project files found")
241
- }
242
- ResolveFailure::NoPackageManager => write!(
243
- formatter,
244
- "package.json exists but no package manager could be selected"
245
- ),
246
- ResolveFailure::NoMatchingScript { candidates } => {
247
- if candidates.is_empty() {
248
- write!(formatter, "no matching script found")
249
- } else {
250
- write!(
251
- formatter,
252
- "no matching script found. Possible candidates: {}",
253
- candidates.join(", ")
254
- )
255
- }
256
- }
257
- ResolveFailure::UnsupportedIntent => {
258
- write!(formatter, "intent is not supported for this project type")
259
- }
260
- }
261
- }
262
- }
package/src/resolve.rs DELETED
@@ -1,269 +0,0 @@
1
- use crate::model::{
2
- CommandSpec, Framework, Intent, ProjectInfo, ResolveFailure, ResolvedCommand, SrunError,
3
- };
4
-
5
- pub fn resolve_intent(project: &ProjectInfo, intent: Intent) -> Result<ResolvedCommand, SrunError> {
6
- if project.is_cargo_only() {
7
- return resolve_cargo(intent);
8
- }
9
-
10
- if !project.has_package_json && !project.has_cargo_toml {
11
- return Err(resolve_error(intent, ResolveFailure::NoProjectDetected));
12
- }
13
-
14
- let package_manager = project
15
- .package_manager
16
- .ok_or_else(|| resolve_error(intent, ResolveFailure::NoPackageManager))?;
17
- let scripts = project.scripts();
18
- let candidates = candidates_for(project, intent);
19
-
20
- for candidate in candidates {
21
- if scripts.contains_key(candidate) {
22
- return Ok(ResolvedCommand {
23
- intent,
24
- command: package_manager.script_command(candidate),
25
- reason: reason_for(project, intent, candidate),
26
- script: Some(candidate.to_string()),
27
- });
28
- }
29
- }
30
-
31
- Err(resolve_error(
32
- intent,
33
- ResolveFailure::NoMatchingScript {
34
- candidates: fallback_candidates(&scripts),
35
- },
36
- ))
37
- }
38
-
39
- pub fn candidates_for(project: &ProjectInfo, intent: Intent) -> Vec<&'static str> {
40
- match intent {
41
- Intent::Dev if project.has_framework(Framework::Electron) => vec![
42
- "dev:electron",
43
- "electron:dev",
44
- "desktop:dev",
45
- "dev",
46
- "dev:web",
47
- "tauri:dev",
48
- "app:dev",
49
- "start",
50
- "serve",
51
- "watch",
52
- ],
53
- Intent::Dev if project.has_framework(Framework::Tauri) => vec![
54
- "tauri:dev",
55
- "dev:tauri",
56
- "desktop:dev",
57
- "dev",
58
- "dev:web",
59
- "dev:electron",
60
- "electron:dev",
61
- "app:dev",
62
- "start",
63
- "serve",
64
- "watch",
65
- ],
66
- Intent::Dev => vec![
67
- "dev",
68
- "dev:web",
69
- "dev:electron",
70
- "electron:dev",
71
- "tauri:dev",
72
- "desktop:dev",
73
- "app:dev",
74
- "start",
75
- "serve",
76
- "watch",
77
- ],
78
- Intent::Build if project.has_framework(Framework::Electron) => vec![
79
- "build:electron",
80
- "electron:build",
81
- "desktop:build",
82
- "build",
83
- "build:web",
84
- "tauri:build",
85
- "dist",
86
- "compile",
87
- "bundle",
88
- ],
89
- Intent::Build if project.has_framework(Framework::Tauri) => vec![
90
- "tauri:build",
91
- "build:tauri",
92
- "desktop:build",
93
- "build",
94
- "build:web",
95
- "build:electron",
96
- "electron:build",
97
- "dist",
98
- "compile",
99
- "bundle",
100
- ],
101
- Intent::Build => vec![
102
- "build",
103
- "build:web",
104
- "build:electron",
105
- "electron:build",
106
- "tauri:build",
107
- "dist",
108
- "compile",
109
- "bundle",
110
- ],
111
- Intent::Installer => vec![
112
- "installer",
113
- "installer:win",
114
- "package",
115
- "make",
116
- "dist",
117
- "release",
118
- "bundle",
119
- ],
120
- Intent::Test => vec!["test", "tests", "check", "ci:test"],
121
- Intent::Lint => vec!["lint", "lint:check", "eslint", "clippy"],
122
- Intent::Format => vec!["format", "fmt", "prettier", "format:check"],
123
- }
124
- }
125
-
126
- fn resolve_cargo(intent: Intent) -> Result<ResolvedCommand, SrunError> {
127
- let command = match intent {
128
- Intent::Dev => CommandSpec::new("cargo", ["run"]),
129
- Intent::Build => CommandSpec::new("cargo", ["build"]),
130
- Intent::Test => CommandSpec::new("cargo", ["test"]),
131
- Intent::Lint => CommandSpec::new("cargo", ["clippy"]),
132
- Intent::Format => CommandSpec::new("cargo", ["fmt"]),
133
- Intent::Installer => return Err(resolve_error(intent, ResolveFailure::UnsupportedIntent)),
134
- };
135
-
136
- Ok(ResolvedCommand {
137
- intent,
138
- command,
139
- reason: "Cargo-only project fallback".to_string(),
140
- script: None,
141
- })
142
- }
143
-
144
- fn reason_for(project: &ProjectInfo, intent: Intent, script: &str) -> String {
145
- if intent == Intent::Dev
146
- && project.has_framework(Framework::Electron)
147
- && script.contains("electron")
148
- {
149
- return "Electron project: desktop dev script has priority".to_string();
150
- }
151
- if intent == Intent::Build
152
- && project.has_framework(Framework::Electron)
153
- && script.contains("electron")
154
- {
155
- return "Electron project: desktop build script has priority".to_string();
156
- }
157
- if project.has_framework(Framework::Tauri) && script.contains("tauri") {
158
- return "Tauri project: tauri script has priority".to_string();
159
- }
160
-
161
- format!("selected first matching {} script", intent.label())
162
- }
163
-
164
- fn fallback_candidates(scripts: &std::collections::BTreeMap<String, String>) -> Vec<String> {
165
- scripts.keys().take(8).cloned().collect()
166
- }
167
-
168
- fn resolve_error(intent: Intent, failure: ResolveFailure) -> SrunError {
169
- SrunError::Resolve { intent, failure }
170
- }
171
-
172
- #[cfg(test)]
173
- mod tests {
174
- use super::{candidates_for, resolve_intent};
175
- use crate::detect::detect_project;
176
- use crate::model::{Framework, Intent};
177
- use std::fs;
178
- use tempfile::tempdir;
179
-
180
- #[test]
181
- fn preserves_base_dev_order_for_simple_projects() {
182
- let dir = tempdir().expect("tempdir");
183
- fs::write(
184
- dir.path().join("package.json"),
185
- r#"{"scripts":{"dev":"next dev"}}"#,
186
- )
187
- .expect("package");
188
-
189
- let project = detect_project(dir.path()).expect("project");
190
- let candidates = candidates_for(&project, Intent::Dev);
191
-
192
- assert_eq!(candidates[0], "dev");
193
- }
194
-
195
- #[test]
196
- fn resolves_next_dev() {
197
- let dir = tempdir().expect("tempdir");
198
- fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("lock");
199
- fs::write(
200
- dir.path().join("package.json"),
201
- r#"{"scripts":{"dev":"next dev"},"dependencies":{"next":"latest"}}"#,
202
- )
203
- .expect("package");
204
-
205
- let project = detect_project(dir.path()).expect("project");
206
- let resolved = resolve_intent(&project, Intent::Dev).expect("resolved");
207
-
208
- assert_eq!(resolved.command.display(), "pnpm run dev");
209
- }
210
-
211
- #[test]
212
- fn prioritizes_electron_dev_script() {
213
- let dir = tempdir().expect("tempdir");
214
- fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("lock");
215
- fs::write(
216
- dir.path().join("package.json"),
217
- r#"{"scripts":{"dev":"vite","dev:electron":"electron-vite dev"},"devDependencies":{"electron":"latest"}}"#,
218
- )
219
- .expect("package");
220
-
221
- let project = detect_project(dir.path()).expect("project");
222
- assert!(project.frameworks.contains(&Framework::Electron));
223
- let resolved = resolve_intent(&project, Intent::Dev).expect("resolved");
224
-
225
- assert_eq!(resolved.command.display(), "pnpm run dev:electron");
226
- }
227
-
228
- #[test]
229
- fn prioritizes_tauri_dev_script() {
230
- let dir = tempdir().expect("tempdir");
231
- fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("lock");
232
- fs::write(
233
- dir.path().join("package.json"),
234
- r#"{"scripts":{"dev":"vite","tauri:dev":"tauri dev"}}"#,
235
- )
236
- .expect("package");
237
-
238
- let project = detect_project(dir.path()).expect("project");
239
- let resolved = resolve_intent(&project, Intent::Dev).expect("resolved");
240
-
241
- assert_eq!(resolved.command.display(), "pnpm run tauri:dev");
242
- }
243
-
244
- #[test]
245
- fn resolves_cargo_project() {
246
- let dir = tempdir().expect("tempdir");
247
- fs::write(dir.path().join("Cargo.toml"), "[package]\nname='x'").expect("cargo");
248
-
249
- let project = detect_project(dir.path()).expect("project");
250
- let resolved = resolve_intent(&project, Intent::Dev).expect("resolved");
251
-
252
- assert_eq!(resolved.command.display(), "cargo run");
253
- }
254
-
255
- #[test]
256
- fn custom_script_returns_candidates_instead_of_guessing() {
257
- let dir = tempdir().expect("tempdir");
258
- fs::write(
259
- dir.path().join("package.json"),
260
- r#"{"scripts":{"banana":"vite"}}"#,
261
- )
262
- .expect("package");
263
-
264
- let project = detect_project(dir.path()).expect("project");
265
- let error = resolve_intent(&project, Intent::Dev).expect_err("should not guess");
266
-
267
- assert!(error.to_string().contains("banana"));
268
- }
269
- }