Skip to main content

nex_pkg/
devenv_import.rs

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}