package jobspec import ( "fmt" "os" "path/filepath" "strings" ) // ParseFile reads a jobspec file from disk and dispatches on file // extension (R-013, REQ-064): // // - .md → ParseMarkdown (canonical Markdown+frontmatter, R-014/R-015) // - .yaml/.yml → ParseMarkdown with the whole file treated as // frontmatter and Body = "" (pure YAML, no Markdown body) // - .hcl → ParseHCL (legacy adapter; wraps the existing HCL parser // and converts Spec{Job, Tasks} into *WorkloadSpec with Kind="Job", // REQ-090 migration window) // // Unknown extensions return an error. The dispatcher preserves // `orca job run old-spec.hcl` during the v0.9→v0.10 migration window // (REQ-090). func ParseFile(path string) (*WorkloadSpec, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read spec file: %w", err) } return Dispatch(data, filepath.Base(path)) } // ParseHCLFile reads an HCL file and parses it via the legacy HCL parser, // returning the legacy *Spec. It is a convenience wrapper retained for // tests and direct HCL consumers that need the raw Spec{Job, Tasks} // shape during the v0.9→v0.10 migration window (REQ-090). // // Deprecated: use ParseFile (dispatcher) for new code. HCL is legacy per // R-013. func ParseHCLFile(path string) (*Spec, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read spec file: %w", err) } return ParseHCLLegacy(data, filepath.Base(path)) } // Dispatch routes raw jobspec bytes on file extension to the // appropriate parser. filename is used only for HCL (the HCL decoder // needs a filename for error messages and syntax sniffing). func Dispatch(data []byte, filename string) (*WorkloadSpec, error) { ext := strings.ToLower(filepath.Ext(filename)) switch ext { case ".md": return ParseMarkdown(data) case ".yaml", ".yml": // Pure YAML file: no Markdown body. Treat the whole file as // the frontmatter block. Body is empty (R-015: no body to // preserve). spec, err := parseYAMLFile(data) if err != nil { return nil, err } return spec, nil case ".hcl": return ParseHCL(data, filename) default: return nil, fmt.Errorf("parse jobspec: unknown extension %q (want .md, .yaml, .yml, or .hcl)", ext) } } // parseYAMLFile treats the whole file as a frontmatter block (no // surrounding `---` delimiters, no Markdown body). This routes .yaml // and .yml files through the same hand-rolled parser as .md. func parseYAMLFile(data []byte) (*WorkloadSpec, error) { block := string(data) if strings.TrimSpace(block) == "" { return nil, fmt.Errorf("parse yaml: empty file") } spec, err := parseFrontmatterBlock(block) if err != nil { return nil, err } spec.Body = "" if err := validateWorkload(spec); err != nil { return nil, err } return spec, nil } // ParseHCL parses a legacy HCL jobspec and adapts it into a *WorkloadSpec // (REQ-064 adapter, REQ-090 migration window). The existing HCL // Spec{Job, Tasks} shape is converted to: // // Kind: "Job" // Name: spec.Job.Name // Runtime: {one_of: "process", command: tasks[0].Command} // // Body is empty (HCL has no Markdown body). The legacy Spec struct and // ParseHCLLegacy are retained for direct HCL consumers that have not yet // migrated. // // Deprecated: use the dispatcher (ParseFile/Dispatch). HCL is legacy // per R-013; the HCL path is retained only for the v0.9→v0.10 migration // window (REQ-090) and will be removed in v1.0. func ParseHCL(data []byte, filename string) (*WorkloadSpec, error) { spec, err := ParseHCLLegacy(data, filename) if err != nil { return nil, err } ws := &WorkloadSpec{ SpecVersion: "", Kind: "Job", Name: spec.Job.Name, Count: 1, Body: "", } if len(spec.Tasks) > 0 { ws.Runtime = &RuntimeBlock{ OneOf: "process", Command: spec.Tasks[0].Command, } } return ws, nil } // ParseHCLLegacy is the original HCL-only parser retained for direct // HCL consumers (e.g. the cli/job.go toTaskSpecs path during the // migration window). New code should call ParseHCL (which returns a // *WorkloadSpec) or the dispatcher. Deprecated: HCL is legacy per // R-013; see ParseHCL. func ParseHCLLegacy(data []byte, filename string) (*Spec, error) { var spec Spec if err := hclDecode(filename, data, &spec); err != nil { return nil, fmt.Errorf("decode hcl: %w", err) } if spec.Job.Name == "" { return nil, fmt.Errorf("spec missing job name") } if len(spec.Tasks) == 0 { return nil, fmt.Errorf("spec must have at least one task") } for i, t := range spec.Tasks { if t.Command == "" { return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name) } } return &spec, nil }