1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8pub const DEVENVP_IMPORT_SCHEMA_V1: &str = "io.styrene.nex.devenv-import-report.v1";
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "camelCase")]
12pub struct DevenvImportReport {
13 pub schema: String,
14 pub root: PathBuf,
15 pub mode: DevenvImportMode,
16 pub detected: DevenvDetectedFiles,
17 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
18 pub source_hashes: BTreeMap<String, String>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub items: Vec<DevenvImportItem>,
21 pub summary: DevenvImportSummary,
22 #[serde(default, skip_serializing_if = "Vec::is_empty")]
23 pub warnings: Vec<DevenvImportMessage>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "kebab-case")]
28pub enum DevenvImportMode {
29 Static,
30}
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "camelCase")]
34pub struct DevenvDetectedFiles {
35 pub devenv_nix: bool,
36 pub devenv_yaml: bool,
37 pub devenv_lock: bool,
38 pub devenv_local_nix: bool,
39 pub devenv_local_yaml: bool,
40 pub envrc: bool,
41 pub secretspec_toml: bool,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "camelCase")]
46pub struct DevenvImportSummary {
47 pub portable: usize,
48 pub project_scoped: usize,
49 pub machine_scoped_candidate: usize,
50 pub requires_review: usize,
51 pub unsupported: usize,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "camelCase")]
56pub struct DevenvImportItem {
57 pub id: String,
58 pub kind: DevenvImportKind,
59 pub bucket: DevenvImportBucket,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub safety: Vec<DevenvSafetyTag>,
62 pub source: DevenvImportSource,
63 pub devenv: DevenvSourceSummary,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub nex_candidate: Option<NexCandidateSummary>,
66 pub review: DevenvReviewState,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub messages: Vec<DevenvImportMessage>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "kebab-case")]
73pub enum DevenvImportKind {
74 Package,
75 Language,
76 Service,
77 Process,
78 Task,
79 ShellHook,
80 Test,
81 Output,
82 Container,
83 SecretContract,
84 DotenvProvider,
85 GitHook,
86 Overlay,
87 Import,
88 BinaryCache,
89 Unknown,
90}
91
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(rename_all = "kebab-case")]
94pub enum DevenvImportBucket {
95 Portable,
96 ProjectScoped,
97 MachineScopedCandidate,
98 RequiresReview,
99 Unsupported,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "kebab-case")]
104pub enum DevenvSafetyTag {
105 ReadOnly,
106 LocalFileWrite,
107 NetworkRead,
108 NetworkWrite,
109 Build,
110 UserConfigMutation,
111 SystemConfigMutation,
112 PrivilegedMutation,
113 HardwareDriverMutation,
114 DestructiveDiskOperation,
115 SecretContract,
116 SecretValueRuntime,
117 IdentitySigning,
118 ArbitraryCommand,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "camelCase")]
123pub struct DevenvImportSource {
124 pub file: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub path: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub line: Option<usize>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "camelCase")]
133pub struct DevenvSourceSummary {
134 pub option: String,
135 pub value_summary: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "camelCase")]
140pub struct NexCandidateSummary {
141 pub target: String,
142 pub value_summary: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "camelCase")]
147pub struct DevenvReviewState {
148 pub required: bool,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub reason: Option<String>,
151 pub resolved: bool,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub resolution: Option<String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct DevenvImportMessage {
158 pub code: String,
159 pub message: String,
160}
161
162pub fn inspect_devenv_project(root: &Path) -> Result<DevenvImportReport> {
163 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
164 let detected = detect_files(&root);
165 let mut report = DevenvImportReport {
166 schema: DEVENVP_IMPORT_SCHEMA_V1.to_string(),
167 root: root.clone(),
168 mode: DevenvImportMode::Static,
169 detected,
170 source_hashes: BTreeMap::new(),
171 items: Vec::new(),
172 summary: DevenvImportSummary::default(),
173 warnings: Vec::new(),
174 };
175
176 collect_hashes(&root, &report.detected, &mut report.source_hashes)?;
177 inspect_devenv_nix(&root, &mut report)?;
178 inspect_devenv_yaml(&root, &mut report)?;
179 inspect_secretspec(&root, &mut report)?;
180 inspect_local_files(&mut report);
181 report.summary = summarize(&report.items);
182 Ok(report)
183}
184
185fn detect_files(root: &Path) -> DevenvDetectedFiles {
186 DevenvDetectedFiles {
187 devenv_nix: root.join("devenv.nix").exists(),
188 devenv_yaml: root.join("devenv.yaml").exists(),
189 devenv_lock: root.join("devenv.lock").exists(),
190 devenv_local_nix: root.join("devenv.local.nix").exists(),
191 devenv_local_yaml: root.join("devenv.local.yaml").exists(),
192 envrc: root.join(".envrc").exists(),
193 secretspec_toml: root.join("secretspec.toml").exists(),
194 }
195}
196
197fn collect_hashes(
198 root: &Path,
199 detected: &DevenvDetectedFiles,
200 hashes: &mut BTreeMap<String, String>,
201) -> Result<()> {
202 for (enabled, file) in [
203 (detected.devenv_nix, "devenv.nix"),
204 (detected.devenv_yaml, "devenv.yaml"),
205 (detected.devenv_lock, "devenv.lock"),
206 (detected.devenv_local_nix, "devenv.local.nix"),
207 (detected.devenv_local_yaml, "devenv.local.yaml"),
208 (detected.envrc, ".envrc"),
209 (detected.secretspec_toml, "secretspec.toml"),
210 ] {
211 if enabled {
212 let bytes =
213 std::fs::read(root.join(file)).with_context(|| format!("reading {file}"))?;
214 hashes.insert(
215 file.to_string(),
216 format!("sha256:{:x}", Sha256::digest(bytes)),
217 );
218 }
219 }
220 Ok(())
221}
222
223fn inspect_devenv_nix(root: &Path, report: &mut DevenvImportReport) -> Result<()> {
224 let path = root.join("devenv.nix");
225 if !path.exists() {
226 return Ok(());
227 }
228 let content = std::fs::read_to_string(&path).context("reading devenv.nix")?;
229 for (needle, kind, bucket, target, safety) in [
230 (
231 "packages",
232 DevenvImportKind::Package,
233 DevenvImportBucket::Portable,
234 "profile.packages",
235 vec![DevenvSafetyTag::Build],
236 ),
237 (
238 "languages.",
239 DevenvImportKind::Language,
240 DevenvImportBucket::Portable,
241 "profile.fragments.dev",
242 vec![DevenvSafetyTag::Build],
243 ),
244 (
245 "services.",
246 DevenvImportKind::Service,
247 DevenvImportBucket::MachineScopedCandidate,
248 "profile.services",
249 vec![DevenvSafetyTag::SystemConfigMutation],
250 ),
251 (
252 "processes",
253 DevenvImportKind::Process,
254 DevenvImportBucket::ProjectScoped,
255 "profile.processes",
256 vec![DevenvSafetyTag::ArbitraryCommand],
257 ),
258 (
259 "tasks.",
260 DevenvImportKind::Task,
261 DevenvImportBucket::RequiresReview,
262 "profile.tasks",
263 vec![DevenvSafetyTag::ArbitraryCommand],
264 ),
265 (
266 "enterShell",
267 DevenvImportKind::ShellHook,
268 DevenvImportBucket::RequiresReview,
269 "profile.shellHooks",
270 vec![DevenvSafetyTag::ArbitraryCommand],
271 ),
272 (
273 "enterTest",
274 DevenvImportKind::Test,
275 DevenvImportBucket::Portable,
276 "profile.tests",
277 vec![DevenvSafetyTag::Build],
278 ),
279 (
280 "outputs",
281 DevenvImportKind::Output,
282 DevenvImportBucket::Portable,
283 "profile.outputs",
284 vec![DevenvSafetyTag::Build],
285 ),
286 (
287 "containers",
288 DevenvImportKind::Container,
289 DevenvImportBucket::Portable,
290 "profile.outputs.container",
291 vec![DevenvSafetyTag::Build],
292 ),
293 ] {
294 if content.contains(needle) {
295 report.items.push(item(
296 format!("devenv.nix:{needle}"),
297 kind,
298 bucket,
299 "devenv.nix",
300 Some(needle.to_string()),
301 needle.to_string(),
302 format!("detected {needle}"),
303 Some((
304 target.to_string(),
305 format!("candidate mapping for {needle}"),
306 )),
307 safety,
308 review_for_bucket(bucket, None),
309 ));
310 }
311 }
312 if content.contains("imports") {
313 report.items.push(item(
314 "devenv.nix:imports".to_string(),
315 DevenvImportKind::Import,
316 DevenvImportBucket::RequiresReview,
317 "devenv.nix",
318 Some("imports".to_string()),
319 "imports".to_string(),
320 "devenv imports require review before profile migration".to_string(),
321 Some((
322 "profile.imports".to_string(),
323 "candidate imports".to_string(),
324 )),
325 vec![DevenvSafetyTag::Build],
326 review_for_bucket(
327 DevenvImportBucket::RequiresReview,
328 Some("devenv imports can execute arbitrary Nix or pull project-local modules"),
329 ),
330 ));
331 }
332 Ok(())
333}
334
335fn inspect_devenv_yaml(root: &Path, report: &mut DevenvImportReport) -> Result<()> {
336 let path = root.join("devenv.yaml");
337 if !path.exists() {
338 return Ok(());
339 }
340 let content = std::fs::read_to_string(&path).context("reading devenv.yaml")?;
341 let yaml: serde_yaml::Value = serde_yaml::from_str(&content).context("parsing devenv.yaml")?;
342 if yaml.get("secretspec").is_some() {
343 report.items.push(item(
344 "devenv.yaml:secretspec".to_string(),
345 DevenvImportKind::SecretContract,
346 DevenvImportBucket::Portable,
347 "devenv.yaml",
348 Some("secretspec".to_string()),
349 "secretspec".to_string(),
350 "SecretSpec integration configured".to_string(),
351 Some((
352 "profile.secrets.provider".to_string(),
353 "provider/profile hint".to_string(),
354 )),
355 vec![DevenvSafetyTag::SecretContract],
356 review_for_bucket(DevenvImportBucket::Portable, None),
357 ));
358 }
359 Ok(())
360}
361
362fn inspect_secretspec(root: &Path, report: &mut DevenvImportReport) -> Result<()> {
363 let path = root.join("secretspec.toml");
364 if !path.exists() {
365 return Ok(());
366 }
367 let content = std::fs::read_to_string(&path).context("reading secretspec.toml")?;
368 let value: toml::Value = toml::from_str(&content).context("parsing secretspec.toml")?;
369 if let Some(profiles) = value.get("profiles").and_then(toml::Value::as_table) {
370 for (profile_name, profile) in profiles {
371 let Some(secrets) = profile.as_table() else {
372 continue;
373 };
374 for secret_name in secrets.keys() {
375 report.items.push(item(
376 format!("secretspec:{profile_name}:{secret_name}"),
377 DevenvImportKind::SecretContract,
378 DevenvImportBucket::Portable,
379 "secretspec.toml",
380 Some(format!("profiles.{profile_name}.{secret_name}")),
381 format!("profiles.{profile_name}.{secret_name}"),
382 format!("secret contract {secret_name}"),
383 Some(("profile.secrets.required".to_string(), secret_name.clone())),
384 vec![DevenvSafetyTag::SecretContract],
385 review_for_bucket(DevenvImportBucket::Portable, None),
386 ));
387 }
388 }
389 }
390 Ok(())
391}
392
393fn inspect_local_files(report: &mut DevenvImportReport) {
394 for (enabled, file) in [
395 (report.detected.devenv_local_nix, "devenv.local.nix"),
396 (report.detected.devenv_local_yaml, "devenv.local.yaml"),
397 (report.detected.envrc, ".envrc"),
398 ] {
399 if enabled {
400 report.items.push(item(
401 format!("local:{file}"),
402 DevenvImportKind::Unknown,
403 DevenvImportBucket::ProjectScoped,
404 file,
405 None,
406 file.to_string(),
407 "local project-only configuration detected".to_string(),
408 None,
409 vec![DevenvSafetyTag::LocalFileWrite],
410 review_for_bucket(DevenvImportBucket::ProjectScoped, None),
411 ));
412 }
413 }
414}
415
416#[allow(clippy::too_many_arguments)]
417fn item(
418 id: String,
419 kind: DevenvImportKind,
420 bucket: DevenvImportBucket,
421 file: &str,
422 source_path: Option<String>,
423 option: String,
424 value_summary: String,
425 nex_candidate: Option<(String, String)>,
426 safety: Vec<DevenvSafetyTag>,
427 review: DevenvReviewState,
428) -> DevenvImportItem {
429 DevenvImportItem {
430 id,
431 kind,
432 bucket,
433 safety,
434 source: DevenvImportSource {
435 file: file.to_string(),
436 path: source_path,
437 line: None,
438 },
439 devenv: DevenvSourceSummary {
440 option,
441 value_summary,
442 },
443 nex_candidate: nex_candidate.map(|(target, value_summary)| NexCandidateSummary {
444 target,
445 value_summary,
446 }),
447 review,
448 messages: Vec::new(),
449 }
450}
451
452fn review_for_bucket(bucket: DevenvImportBucket, reason: Option<&str>) -> DevenvReviewState {
453 DevenvReviewState {
454 required: bucket == DevenvImportBucket::RequiresReview,
455 reason: reason.map(ToString::to_string).or_else(|| {
456 (bucket == DevenvImportBucket::RequiresReview)
457 .then(|| "requires operator review before migration".to_string())
458 }),
459 resolved: false,
460 resolution: None,
461 }
462}
463
464fn summarize(items: &[DevenvImportItem]) -> DevenvImportSummary {
465 let mut summary = DevenvImportSummary::default();
466 for item in items {
467 match item.bucket {
468 DevenvImportBucket::Portable => summary.portable += 1,
469 DevenvImportBucket::ProjectScoped => summary.project_scoped += 1,
470 DevenvImportBucket::MachineScopedCandidate => summary.machine_scoped_candidate += 1,
471 DevenvImportBucket::RequiresReview => summary.requires_review += 1,
472 DevenvImportBucket::Unsupported => summary.unsupported += 1,
473 }
474 }
475 summary
476}
477
478pub const DEVENVP_MIGRATION_PLAN_SCHEMA_V1: &str = "io.styrene.nex.devenv-migration-plan.v1";
479
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
481#[serde(rename_all = "camelCase")]
482pub struct DevenvMigrationPlan {
483 pub schema: String,
484 pub root: PathBuf,
485 pub import_schema: String,
486 pub summary: DevenvImportSummary,
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
488 pub actions: Vec<DevenvMigrationAction>,
489 #[serde(default, skip_serializing_if = "Vec::is_empty")]
490 pub blocked: Vec<DevenvMigrationAction>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
494#[serde(rename_all = "camelCase")]
495pub struct DevenvMigrationAction {
496 pub id: String,
497 pub kind: DevenvImportKind,
498 pub bucket: DevenvImportBucket,
499 pub source: DevenvImportSource,
500 pub action: DevenvMigrationActionKind,
501 pub target: String,
502 pub value_summary: String,
503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
504 pub safety: Vec<DevenvSafetyTag>,
505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
506 pub blockers: Vec<DevenvImportMessage>,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
510#[serde(rename_all = "kebab-case")]
511pub enum DevenvMigrationActionKind {
512 GenerateProfileFragment,
513 GenerateSecretContract,
514 GenerateProfileOutput,
515 ManualReview,
516 SkipLocalOnly,
517}
518
519pub fn plan_devenv_migration(root: &Path) -> Result<DevenvMigrationPlan> {
520 let report = inspect_devenv_project(root)?;
521 let mut actions = Vec::new();
522 let mut blocked = Vec::new();
523 for item in &report.items {
524 let target = item
525 .nex_candidate
526 .as_ref()
527 .map(|candidate| candidate.target.clone())
528 .unwrap_or_else(|| "manual-review".to_string());
529 let action = action_for_item(item);
530 let mut migration = DevenvMigrationAction {
531 id: item.id.clone(),
532 kind: item.kind.clone(),
533 bucket: item.bucket,
534 source: item.source.clone(),
535 action,
536 target,
537 value_summary: item.devenv.value_summary.clone(),
538 safety: item.safety.clone(),
539 blockers: Vec::new(),
540 };
541 if item.review.required || item.bucket == DevenvImportBucket::MachineScopedCandidate {
542 migration.blockers.push(DevenvImportMessage {
543 code: "REVIEW_REQUIRED".to_string(),
544 message: item
545 .review
546 .reason
547 .clone()
548 .unwrap_or_else(|| "operator review required before migration".to_string()),
549 });
550 }
551 if migration.blockers.is_empty() {
552 actions.push(migration);
553 } else {
554 blocked.push(migration);
555 }
556 }
557 Ok(DevenvMigrationPlan {
558 schema: DEVENVP_MIGRATION_PLAN_SCHEMA_V1.to_string(),
559 root: report.root,
560 import_schema: report.schema,
561 summary: report.summary,
562 actions,
563 blocked,
564 })
565}
566
567fn action_for_item(item: &DevenvImportItem) -> DevenvMigrationActionKind {
568 match item.bucket {
569 DevenvImportBucket::ProjectScoped => return DevenvMigrationActionKind::SkipLocalOnly,
570 DevenvImportBucket::RequiresReview
571 | DevenvImportBucket::MachineScopedCandidate
572 | DevenvImportBucket::Unsupported => return DevenvMigrationActionKind::ManualReview,
573 DevenvImportBucket::Portable => {}
574 }
575 match item.kind {
576 DevenvImportKind::SecretContract | DevenvImportKind::DotenvProvider => {
577 DevenvMigrationActionKind::GenerateSecretContract
578 }
579 DevenvImportKind::Output | DevenvImportKind::Container => {
580 DevenvMigrationActionKind::GenerateProfileOutput
581 }
582 _ => DevenvMigrationActionKind::GenerateProfileFragment,
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn statically_inspects_devenv_project_with_secretspec() -> Result<()> {
592 let dir = tempfile::tempdir()?;
593 std::fs::write(
594 dir.path().join("devenv.nix"),
595 r#"{ pkgs, ... }: {
596 packages = [ pkgs.git ];
597 languages.rust.enable = true;
598 services.postgres.enable = true;
599 enterShell = ''echo hello'';
600 containers.shell.copyToRoot = null;
601 }"#,
602 )?;
603 std::fs::write(
604 dir.path().join("devenv.yaml"),
605 "secretspec:\n enable: true\n provider: keyring\n profile: default\n",
606 )?;
607 std::fs::write(
608 dir.path().join("secretspec.toml"),
609 r#"[project]
610name = "example"
611revision = "1.0"
612
613[profiles.default]
614DATABASE_URL = { description = "Database URL", required = true }
615API_TOKEN = { description = "API token", required = false }
616"#,
617 )?;
618
619 let report = inspect_devenv_project(dir.path())?;
620
621 assert!(report.detected.devenv_nix);
622 assert!(report.detected.devenv_yaml);
623 assert!(report.detected.secretspec_toml);
624 assert!(report.summary.portable >= 5);
625 assert_eq!(report.summary.machine_scoped_candidate, 1);
626 assert_eq!(report.summary.requires_review, 1);
627 assert!(report
628 .items
629 .iter()
630 .any(|item| item.id == "secretspec:default:DATABASE_URL"));
631 Ok(())
632 }
633}