From 172215717d5c7029a82324f3afa52fa42272ff26 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Mon, 6 Apr 2026 02:19:43 +0200 Subject: [PATCH 1/3] refactor: split internal/moltark into model, filefmt, module, engine packages The monolithic internal/moltark package (20+ source files) made it hard to reason about boundaries and would not scale as new modules and file formats are added. Split it into four focused packages with a clean, acyclic dependency graph: model <- filefmt <- module <- engine - model: shared domain types, constants, clone helpers (zero deps) - filefmt: structured-file handlers (TOML/JSON/YAML/gitattributes/paths) - module: Starlark DSL, config loading, core/python/uv modules - engine: 5-phase pipeline, resolve, plan, state, service layer All Go and Bazel tests pass. CLAUDE.md updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 21 +- internal/command/BUILD.bazel | 6 +- internal/command/apply.go | 6 +- internal/command/doctor.go | 4 +- internal/command/init.go | 7 +- internal/command/meta.go | 6 +- internal/command/plan.go | 9 +- internal/engine/BUILD.bazel | 40 +++ internal/{moltark => engine}/pipeline.go | 152 +++++----- internal/{moltark => engine}/pipeline_test.go | 2 +- internal/{moltark => engine}/plan.go | 129 ++++----- internal/{moltark => engine}/plan_test.go | 38 +-- internal/{moltark => engine}/resolve.go | 262 +++++++----------- internal/engine/resolve_test.go | 181 ++++++++++++ internal/{moltark => engine}/service.go | 107 +++---- internal/{moltark => engine}/service_test.go | 5 +- internal/{moltark => engine}/state.go | 81 +++--- internal/{moltark => engine}/state_test.go | 6 +- internal/filefmt/BUILD.bazel | 35 +++ .../{moltark => filefmt}/gitattributes.go | 12 +- .../gitattributes_test.go | 4 +- .../{moltark/jsonfile.go => filefmt/json.go} | 22 +- .../jsonfile_test.go => filefmt/json_test.go} | 8 +- .../structured_paths.go => filefmt/paths.go} | 22 +- .../paths_test.go} | 14 +- .../{moltark/tomlfile.go => filefmt/toml.go} | 37 +-- .../tomlfile_test.go => filefmt/toml_test.go} | 38 +-- .../{moltark/yamlfile.go => filefmt/yaml.go} | 19 +- .../yamlfile_test.go => filefmt/yaml_test.go} | 8 +- internal/model/BUILD.bazel | 11 + internal/{moltark => model}/constants.go | 20 +- internal/{moltark => model}/types.go | 103 +++++-- internal/module/BUILD.bazel | 38 +++ internal/{moltark => module}/config.go | 96 +++---- internal/{moltark => module}/config_test.go | 8 +- .../starlark_convert.go => module/convert.go} | 5 +- .../module_core.go => module/core.go} | 102 +++---- .../core_test.go} | 2 +- .../fact_ref_value.go => module/factref.go} | 2 +- internal/module/facts_test.go | 11 + internal/{moltark => module}/pyproject.go | 20 +- .../module_python.go => module/python.go} | 43 +-- .../modules.go => module/registry.go} | 17 +- .../{moltark/module_uv.go => module/uv.go} | 65 ++--- internal/moltark/BUILD.bazel | 66 ----- internal/moltark/facts_test.go | 141 ---------- internal/moltark/resolve_test.go | 40 --- internal/testutil/BUILD.bazel | 2 +- internal/testutil/testutil.go | 4 +- 49 files changed, 1096 insertions(+), 981 deletions(-) create mode 100644 internal/engine/BUILD.bazel rename internal/{moltark => engine}/pipeline.go (54%) rename internal/{moltark => engine}/pipeline_test.go (99%) rename internal/{moltark => engine}/plan.go (71%) rename internal/{moltark => engine}/plan_test.go (55%) rename internal/{moltark => engine}/resolve.go (69%) create mode 100644 internal/engine/resolve_test.go rename internal/{moltark => engine}/service.go (54%) rename internal/{moltark => engine}/service_test.go (92%) rename internal/{moltark => engine}/state.go (58%) rename internal/{moltark => engine}/state_test.go (86%) create mode 100644 internal/filefmt/BUILD.bazel rename internal/{moltark => filefmt}/gitattributes.go (80%) rename internal/{moltark => filefmt}/gitattributes_test.go (86%) rename internal/{moltark/jsonfile.go => filefmt/json.go} (54%) rename internal/{moltark/jsonfile_test.go => filefmt/json_test.go} (85%) rename internal/{moltark/structured_paths.go => filefmt/paths.go} (88%) rename internal/{moltark/structured_paths_test.go => filefmt/paths_test.go} (77%) rename internal/{moltark/tomlfile.go => filefmt/toml.go} (91%) rename internal/{moltark/tomlfile_test.go => filefmt/toml_test.go} (86%) rename internal/{moltark/yamlfile.go => filefmt/yaml.go} (56%) rename internal/{moltark/yamlfile_test.go => filefmt/yaml_test.go} (86%) create mode 100644 internal/model/BUILD.bazel rename internal/{moltark => model}/constants.go (82%) rename internal/{moltark => model}/types.go (88%) create mode 100644 internal/module/BUILD.bazel rename internal/{moltark => module}/config.go (68%) rename internal/{moltark => module}/config_test.go (69%) rename internal/{moltark/starlark_convert.go => module/convert.go} (94%) rename internal/{moltark/module_core.go => module/core.go} (86%) rename internal/{moltark/module_core_test.go => module/core_test.go} (97%) rename internal/{moltark/fact_ref_value.go => module/factref.go} (99%) create mode 100644 internal/module/facts_test.go rename internal/{moltark => module}/pyproject.go (53%) rename internal/{moltark/module_python.go => module/python.go} (68%) rename internal/{moltark/modules.go => module/registry.go} (64%) rename internal/{moltark/module_uv.go => module/uv.go} (61%) delete mode 100644 internal/moltark/BUILD.bazel delete mode 100644 internal/moltark/facts_test.go delete mode 100644 internal/moltark/resolve_test.go diff --git a/CLAUDE.md b/CLAUDE.md index bfed79c..de6c7f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,9 @@ go build ./cmd/moltark # Go build (quick local check) bazelisk test //... # Full Bazel suite (CI-authoritative) go test ./... # Full Go suite go test -count=1 ./... # Full suite (no cache) -go test -count=1 ./internal/moltark/... # Core package tests +go test -count=1 ./internal/engine/... # Engine tests (pipeline, resolve, plan, state) +go test -count=1 ./internal/filefmt/... # File format handler tests +go test -count=1 ./internal/module/... # Starlark module tests go test -count=1 ./tests/integration/... # Integration tests only go test -count=1 ./tests/features/... # Gherkin feature tests only @@ -42,24 +44,19 @@ Use `-count=1` when changing snapshots or CLI behavior to avoid stale test-cache **CLI layer**: `cmd/moltark/main.go` -> `internal/cliapp/app.go` -> `internal/command/` (one file per subcommand: init, plan, apply, show, doctor, version). Uses `github.com/mitchellh/cli`. -**Engine pipeline** (`internal/moltark/pipeline.go`): Five sequential phases: -1. **Evaluate** - load `molt.star` via Starlark (`config.go`) into `DesiredModel` (projects + components) -2. **Resolve** - resolve facts, providers, routed intents (`resolve.go`) into `ResolvedModel` with managed files -3. **Inspect** - read current repo state: structured files (TOML/JSON/YAML), `.moltark/state.json`, `.gitattributes` -4. **Persist** - build next state from desired+resolved model -5. **Plan** - classify each owned path as create/update/no-op/drift/conflict (`plan.go`) +**Package layout** (acyclic dependency order: model <- filefmt <- module <- engine): -**Service** (`internal/moltark/service.go`): Orchestrates `Plan`, `Apply`, `Show`, `Doctor` operations. Apply re-runs the full pipeline and verifies intent hasn't changed before writing. +- **`internal/model`** — Shared domain types and constants. All IR types (`DesiredModel`, `ResolvedModel`, `Plan`, `Change`, `State`, etc.), change status/reason enums, file format constants, module source identifiers, and clone helpers. Zero internal dependencies. -**First-party modules** (`internal/moltark/module_*.go`): `moltark/core`, `moltark/python`, `astral/uv`. Go and Rust are target ecosystems but do not yet have first-party module depth. +- **`internal/filefmt`** — Structured file format handlers. Path resolution for TOML (dot notation) and JSON/YAML (JSON Pointer), format-specific parsers/mutators (TOML, JSON, YAML), `.gitattributes` managed-block logic. Depends on model only. -**Structured file mutation**: Format-specific mutators for TOML (`pyproject.go`), JSON (`jsonfile.go`), YAML (`yamlfile.go`) that write only owned paths. +- **`internal/module`** — Starlark DSL and module system. Config loading (`LoadDesiredModel`, `InitRepository`), module registry, first-party modules (`moltark/core`, `moltark/python`, `astral/uv`), Starlark value conversion, and fact-ref value type. Depends on model + filefmt. -**Types** (`internal/moltark/types.go`): All IR types -- `DesiredModel`, `ResolvedModel`, `Pipeline`, `Plan`, `Change`, `State`, etc. +- **`internal/engine`** — Reconciliation engine. Five-phase pipeline (evaluate -> resolve -> inspect -> persist -> plan), service layer (`Plan`, `Apply`, `Show`, `Doctor`), change classification, state management, and plan rendering. Depends on model + filefmt + module. ## Testing Structure -- **Package tests**: `internal/moltark/*_test.go` -- planner, resolver, mutator, state logic +- **Package tests**: `internal/engine/*_test.go` (pipeline, resolve, plan, state), `internal/filefmt/*_test.go` (format handlers, paths), `internal/module/*_test.go` (config loading, core module) - **Integration snapshots**: `tests/integration/` -- copies fixture repos to temp dirs, runs CLI commands, snapshots output via `go-snaps` - **Gherkin features**: `tests/features/` -- behavioral scenarios via `godog` - **Fixtures**: `tests/fixtures/` -- real repository structures (molt.star + pyproject.toml + state.json) diff --git a/internal/command/BUILD.bazel b/internal/command/BUILD.bazel index c6729b8..b684e03 100644 --- a/internal/command/BUILD.bazel +++ b/internal/command/BUILD.bazel @@ -13,5 +13,9 @@ go_library( ], importpath = "github.com/ophidiarium/moltark/internal/command", visibility = ["//:__subpackages__"], - deps = ["//internal/moltark"], + deps = [ + "//internal/engine", + "//internal/model", + "//internal/module", + ], ) diff --git a/internal/command/apply.go b/internal/command/apply.go index 3ee690f..86da5b6 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -3,7 +3,7 @@ package command import ( "flag" - "github.com/ophidiarium/moltark/internal/moltark" + "github.com/ophidiarium/moltark/internal/engine" ) type ApplyCommand struct { @@ -26,7 +26,7 @@ func (c *ApplyCommand) Run(args []string) int { return 1 } - c.printLine(moltark.RenderPlan(plan)) + c.printLine(engine.RenderPlan(plan)) if plan.HasConflicts() { c.errorf("Apply aborted due to conflicts.\n") return 1 @@ -53,7 +53,7 @@ func (c *ApplyCommand) Run(args []string) int { } c.printLine("") - c.printLine(moltark.RenderApply(result)) + c.printLine(engine.RenderApply(result)) return 0 } diff --git a/internal/command/doctor.go b/internal/command/doctor.go index 6d4a9cd..5e2fb22 100644 --- a/internal/command/doctor.go +++ b/internal/command/doctor.go @@ -3,7 +3,7 @@ package command import ( "flag" - "github.com/ophidiarium/moltark/internal/moltark" + "github.com/ophidiarium/moltark/internal/engine" ) type DoctorCommand struct { @@ -24,7 +24,7 @@ func (c *DoctorCommand) Run(args []string) int { return 1 } - c.printLine(moltark.RenderDoctor(report)) + c.printLine(engine.RenderDoctor(report)) if report.HasIssues { return 1 } diff --git a/internal/command/init.go b/internal/command/init.go index 2903396..3dbf41b 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -4,7 +4,8 @@ import ( "flag" "fmt" - "github.com/ophidiarium/moltark/internal/moltark" + "github.com/ophidiarium/moltark/internal/model" + "github.com/ophidiarium/moltark/internal/module" ) type InitCommand struct { @@ -19,7 +20,7 @@ func (c *InitCommand) Run(args []string) int { return 1 } - result, err := moltark.InitRepository(c.WorkingDir) + result, err := module.InitRepository(c.WorkingDir) if err != nil { c.errorf("Error: %s\n", err) return 1 @@ -36,7 +37,7 @@ func (c *InitCommand) Help() string { %s when one does not already exist. This command does not reconcile pyproject.toml. Run "moltark plan" - and "moltark apply" after initialization.`, moltark.ProjectSpecFileName) + and "moltark apply" after initialization.`, model.ProjectSpecFileName) } func (c *InitCommand) Synopsis() string { diff --git a/internal/command/meta.go b/internal/command/meta.go index 7d2ca2c..3c6f173 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -6,7 +6,7 @@ import ( "io" "strings" - "github.com/ophidiarium/moltark/internal/moltark" + "github.com/ophidiarium/moltark/internal/engine" ) type Meta struct { @@ -16,8 +16,8 @@ type Meta struct { Stderr io.Writer } -func (m Meta) service() moltark.Service { - return moltark.NewService() +func (m Meta) service() engine.Service { + return engine.NewService() } func (m Meta) printf(format string, args ...any) { diff --git a/internal/command/plan.go b/internal/command/plan.go index 6b7cde4..970e02b 100644 --- a/internal/command/plan.go +++ b/internal/command/plan.go @@ -4,7 +4,8 @@ import ( "flag" "fmt" - "github.com/ophidiarium/moltark/internal/moltark" + "github.com/ophidiarium/moltark/internal/engine" + "github.com/ophidiarium/moltark/internal/model" ) type PlanCommand struct { @@ -26,7 +27,7 @@ func (c *PlanCommand) Run(args []string) int { return 1 } - c.printLine(moltark.RenderPlan(plan)) + c.printLine(engine.RenderPlan(plan)) if plan.HasConflicts() { return 1 @@ -47,9 +48,9 @@ func (c *PlanCommand) Help() string { Options: - -detailed-exitcode Return 2 when changes are planned.`, moltark.ProjectSpecFileName) + -detailed-exitcode Return 2 when changes are planned.`, model.ProjectSpecFileName) } func (c *PlanCommand) Synopsis() string { - return fmt.Sprintf("Show repository changes required by %s", moltark.ProjectSpecFileName) + return fmt.Sprintf("Show repository changes required by %s", model.ProjectSpecFileName) } diff --git a/internal/engine/BUILD.bazel b/internal/engine/BUILD.bazel new file mode 100644 index 0000000..e8a0cb6 --- /dev/null +++ b/internal/engine/BUILD.bazel @@ -0,0 +1,40 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "engine", + srcs = [ + "pipeline.go", + "plan.go", + "resolve.go", + "service.go", + "state.go", + ], + importpath = "github.com/ophidiarium/moltark/internal/engine", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/filefmt", + "//internal/model", + "//internal/module", + ], +) + +go_test( + name = "engine_test", + size = "small", + srcs = [ + "pipeline_test.go", + "plan_test.go", + "resolve_test.go", + "service_test.go", + "state_test.go", + ], + data = [ + "//tests/fixtures:all_fixtures", + ], + embed = [":engine"], + deps = [ + "//internal/filefmt", + "//internal/model", + "//internal/testrepo", + ], +) diff --git a/internal/moltark/pipeline.go b/internal/engine/pipeline.go similarity index 54% rename from internal/moltark/pipeline.go rename to internal/engine/pipeline.go index b645eea..f8e8bb3 100644 --- a/internal/moltark/pipeline.go +++ b/internal/engine/pipeline.go @@ -1,10 +1,28 @@ -package moltark +package engine import ( "fmt" "path/filepath" + + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" + "github.com/ophidiarium/moltark/internal/module" ) +type Pipeline struct { + Root string `json:"-"` + Evaluate model.EvaluationPhase `json:"evaluate"` + Resolve model.ResolutionPhase `json:"resolve"` + Inspect model.InspectionPhase `json:"inspect"` + Persist model.PersistPhase `json:"persist"` + Plan model.PlanningPhase `json:"plan"` + + fileDocs map[string]model.FileDocument + stateRaw string + nextStateRaw string + gitattributesRaw string +} + func (s Service) BuildPipeline(root string) (Pipeline, error) { evaluate, err := evaluatePhase(root) if err != nil { @@ -45,47 +63,47 @@ func (s Service) BuildPipeline(root string) (Pipeline, error) { }, nil } -func (p Pipeline) PlanResult() Plan { - return Plan{ +func (p Pipeline) PlanResult() model.Plan { + return model.Plan{ Desired: p.Evaluate.Desired, Resolved: p.Resolve.Resolved, NextState: p.Persist.NextState, - Changes: append([]Change(nil), p.Plan.Changes...), + Changes: append([]model.Change(nil), p.Plan.Changes...), Summary: p.Plan.Summary, } } -func evaluatePhase(root string) (EvaluationPhase, error) { - desired, err := LoadDesiredModel(root) +func evaluatePhase(root string) (model.EvaluationPhase, error) { + desired, err := module.LoadDesiredModel(root) if err != nil { - return EvaluationPhase{}, err + return model.EvaluationPhase{}, err } - return EvaluationPhase{Desired: desired}, nil + return model.EvaluationPhase{Desired: desired}, nil } -func resolvePhase(evaluate EvaluationPhase) (ResolutionPhase, error) { +func resolvePhase(evaluate model.EvaluationPhase) (model.ResolutionPhase, error) { resolved, err := ResolveModel(evaluate.Desired) if err != nil { - return ResolutionPhase{}, err + return model.ResolutionPhase{}, err } - return ResolutionPhase{Resolved: resolved}, nil + return model.ResolutionPhase{Resolved: resolved}, nil } type inspectionResult struct { - Phase InspectionPhase - FileDocs map[string]fileDocument + Phase model.InspectionPhase + FileDocs map[string]model.FileDocument StateRaw string GitattributesRaw string } -func inspectPhase(root string, resolve ResolutionPhase) (inspectionResult, error) { - fileDocs := make(map[string]fileDocument, len(resolve.Resolved.ManagedFiles)) - phase := InspectionPhase{ - StateFile: filepath.ToSlash(filepath.Join(StateDirName, StateFileName)), - StructuredFiles: make([]InspectedStructuredFile, 0, len(resolve.Resolved.ManagedFiles)), +func inspectPhase(root string, resolve model.ResolutionPhase) (inspectionResult, error) { + fileDocs := make(map[string]model.FileDocument, len(resolve.Resolved.ManagedFiles)) + phase := model.InspectionPhase{ + StateFile: filepath.ToSlash(filepath.Join(model.StateDirName, model.StateFileName)), + StructuredFiles: make([]model.InspectedStructuredFile, 0, len(resolve.Resolved.ManagedFiles)), } - gitattributesRaw, gitattributesExists, err := readOptionalFile(filepath.Join(root, GitattributesFileName)) + gitattributesRaw, gitattributesExists, err := readOptionalFile(filepath.Join(root, model.GitattributesFileName)) if err != nil { return inspectionResult{}, err } @@ -103,14 +121,14 @@ func inspectPhase(root string, resolve ResolutionPhase) (inspectionResult, error } fileDocs[managedFile.Path] = doc - inspected := InspectedStructuredFile{ + inspected := model.InspectedStructuredFile{ Path: managedFile.Path, Format: managedFile.Format, Exists: doc.Exists, OwnedValues: map[string]any{}, } for _, ownedPath := range managedFile.OwnedPaths { - value, ok := lookupStructuredValue(doc.Values, managedFile.Format, ownedPath) + value, ok := filefmt.LookupStructuredValue(doc.Values, managedFile.Format, ownedPath) if ok { inspected.OwnedValues[ownedPath] = value } @@ -122,7 +140,7 @@ func inspectPhase(root string, resolve ResolutionPhase) (inspectionResult, error if len(managedFile.UserManagedPaths) > 0 { inspected.UserManagedValues = map[string]any{} for _, userManagedPath := range managedFile.UserManagedPaths { - value, ok := lookupStructuredValue(doc.Values, managedFile.Format, userManagedPath) + value, ok := filefmt.LookupStructuredValue(doc.Values, managedFile.Format, userManagedPath) if ok && value != nil { inspected.UserManagedValues[userManagedPath] = value } @@ -135,10 +153,10 @@ func inspectPhase(root string, resolve ResolutionPhase) (inspectionResult, error phase.StructuredFiles = append(phase.StructuredFiles, inspected) } - block, ok := currentManagedGitattributesBlock(gitattributesRaw) - phase.Gitattributes = GitattributesInspection{ + block, ok := filefmt.CurrentManagedGitattributesBlock(gitattributesRaw) + phase.Gitattributes = model.GitattributesInspection{ Exists: gitattributesExists, - ManagedBlock: renderDisplayValue(FileFormatTOML, block, ok), + ManagedBlock: renderDisplayValue(model.FileFormatTOML, block, ok), } return inspectionResult{ @@ -149,53 +167,53 @@ func inspectPhase(root string, resolve ResolutionPhase) (inspectionResult, error }, nil } -func persistPhase(evaluate EvaluationPhase, resolve ResolutionPhase) (PersistPhase, string, error) { +func persistPhase(evaluate model.EvaluationPhase, resolve model.ResolutionPhase) (model.PersistPhase, string, error) { nextState, err := buildState(evaluate.Desired, resolve.Resolved) if err != nil { - return PersistPhase{}, "", err + return model.PersistPhase{}, "", err } nextStateRaw, err := renderState(nextState) if err != nil { - return PersistPhase{}, "", err + return model.PersistPhase{}, "", err } - return PersistPhase{ - StateFile: filepath.ToSlash(filepath.Join(StateDirName, StateFileName)), + return model.PersistPhase{ + StateFile: filepath.ToSlash(filepath.Join(model.StateDirName, model.StateFileName)), NextState: &nextState, }, nextStateRaw, nil } func planningPhase( - resolve ResolutionPhase, - inspect InspectionPhase, - persist PersistPhase, - docs map[string]fileDocument, + resolve model.ResolutionPhase, + inspect model.InspectionPhase, + persist model.PersistPhase, + docs map[string]model.FileDocument, stateRaw string, nextStateRaw string, gitattributesRaw string, -) (PlanningPhase, error) { - changes := make([]Change, 0, len(resolve.Resolved.ManagedFiles)*8+4) +) (model.PlanningPhase, error) { + changes := make([]model.Change, 0, len(resolve.Resolved.ManagedFiles)*8+4) for _, managedFile := range resolve.Resolved.ManagedFiles { doc, ok := docs[managedFile.Path] if !ok { - return PlanningPhase{}, fmt.Errorf("inspection missing structured file %s", managedFile.Path) + return model.PlanningPhase{}, fmt.Errorf("inspection missing structured file %s", managedFile.Path) } if !doc.Exists { - changes = append(changes, Change{ - Status: ChangeCreate, + changes = append(changes, model.Change{ + Status: model.ChangeCreate, File: managedFile.Path, - Reason: ReasonBootstrap, + Reason: model.ReasonBootstrap, Summary: fmt.Sprintf("create %s", managedFile.Path), }) } stateFile := stateManagedFile(inspect.CurrentState, managedFile.Path) for _, ownedPath := range managedFile.OwnedPaths { - desiredValue, err := requireStructuredValue(managedFile.DesiredValues, managedFile.Format, ownedPath) + desiredValue, err := filefmt.RequireStructuredValue(managedFile.DesiredValues, managedFile.Format, ownedPath) if err != nil { - return PlanningPhase{}, fmt.Errorf("plan %s %s: %w", managedFile.Path, ownedPath, err) + return model.PlanningPhase{}, fmt.Errorf("plan %s %s: %w", managedFile.Path, ownedPath, err) } - actualValue, actualPresent := lookupStructuredValue(doc.Values, managedFile.Format, ownedPath) + actualValue, actualPresent := filefmt.LookupStructuredValue(doc.Values, managedFile.Format, ownedPath) ownerComponentID := managedFile.OwnedPathOwners[ownedPath] desiredVersion := managedFile.OwnedPathVersions[ownedPath] change, err := classifyPath( @@ -211,73 +229,73 @@ func planningPhase( inspect.CurrentState, ) if err != nil { - return PlanningPhase{}, err + return model.PlanningPhase{}, err } changes = append(changes, change) } for _, userManagedPath := range managedFile.UserManagedPaths { - if value, ok := lookupStructuredValue(doc.Values, managedFile.Format, userManagedPath); ok && value != nil { - changes = append(changes, Change{ - Status: ChangeNoOp, + if value, ok := filefmt.LookupStructuredValue(doc.Values, managedFile.Format, userManagedPath); ok && value != nil { + changes = append(changes, model.Change{ + Status: model.ChangeNoOp, File: managedFile.Path, Path: userManagedPath, - Reason: ReasonAdoption, + Reason: model.ReasonAdoption, Summary: noOpSummary(userManagedPath), }) } } } - block, blockExists := currentManagedGitattributesBlock(gitattributesRaw) - gitattributesState := stateManagedFile(inspect.CurrentState, GitattributesFileName) + block, blockExists := filefmt.CurrentManagedGitattributesBlock(gitattributesRaw) + gitattributesState := stateManagedFile(inspect.CurrentState, model.GitattributesFileName) if !inspect.Gitattributes.Exists { - changes = append(changes, Change{ - Status: ChangeCreate, - File: GitattributesFileName, - Reason: ReasonBootstrap, + changes = append(changes, model.Change{ + Status: model.ChangeCreate, + File: model.GitattributesFileName, + Reason: model.ReasonBootstrap, Summary: "create .gitattributes", }) } change, err := classifyPath( - FileFormatTOML, - GitattributesFileName, + model.FileFormatTOML, + model.GitattributesFileName, "moltark.block", "", "", - managedGitattributesBlock(), + filefmt.ManagedGitattributesBlock(), block, blockExists, gitattributesState, inspect.CurrentState, ) if err != nil { - return PlanningPhase{}, err + return model.PlanningPhase{}, err } changes = append(changes, change) if !hasConflict(changes) && persist.NextState != nil { if inspect.CurrentState == nil { - changes = append(changes, Change{ - Status: ChangeCreate, + changes = append(changes, model.Change{ + Status: model.ChangeCreate, File: persist.StateFile, - Reason: ReasonBootstrap, + Reason: model.ReasonBootstrap, Summary: "initialize .moltark/state.json", }) } else { if nextStateRaw != stateRaw { reason := reasonForStateUpdate(inspect.CurrentState, persist.NextState) - changes = append(changes, Change{ - Status: ChangeUpdate, + changes = append(changes, model.Change{ + Status: model.ChangeUpdate, File: persist.StateFile, Reason: reason, Summary: "update .moltark/state.json", }) } else { - changes = append(changes, Change{ - Status: ChangeNoOp, + changes = append(changes, model.Change{ + Status: model.ChangeNoOp, File: persist.StateFile, - Reason: ReasonAdoption, + Reason: model.ReasonAdoption, Summary: "no change to .moltark/state.json", }) } @@ -285,7 +303,7 @@ func planningPhase( } compacted := compactChanges(changes) - return PlanningPhase{ + return model.PlanningPhase{ Changes: compacted, Summary: summarizeChanges(compacted), }, nil diff --git a/internal/moltark/pipeline_test.go b/internal/engine/pipeline_test.go similarity index 99% rename from internal/moltark/pipeline_test.go rename to internal/engine/pipeline_test.go index 6547c0c..93bf555 100644 --- a/internal/moltark/pipeline_test.go +++ b/internal/engine/pipeline_test.go @@ -1,4 +1,4 @@ -package moltark +package engine import ( "os" diff --git a/internal/moltark/plan.go b/internal/engine/plan.go similarity index 71% rename from internal/moltark/plan.go rename to internal/engine/plan.go index a6e2d2d..9e1c149 100644 --- a/internal/moltark/plan.go +++ b/internal/engine/plan.go @@ -1,51 +1,54 @@ -package moltark +package engine import ( "fmt" "path/filepath" "strings" + + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" ) -func classifyPath(format string, file string, path string, ownerComponentID string, desiredVersion string, desiredValue any, actualValue any, actualPresent bool, stateFile *ManagedFileState, state *State) (Change, error) { +func classifyPath(format string, file string, path string, ownerComponentID string, desiredVersion string, desiredValue any, actualValue any, actualPresent bool, stateFile *model.ManagedFileState, state *model.State) (model.Change, error) { desiredFingerprint, err := fingerprintValue(desiredValue, true) if err != nil { - return Change{}, fmt.Errorf("fingerprint desired value for %s %s: %w", file, path, err) + return model.Change{}, fmt.Errorf("fingerprint desired value for %s %s: %w", file, path, err) } actualFingerprint, err := fingerprintValue(actualValue, actualPresent) if err != nil { - return Change{}, fmt.Errorf("fingerprint actual value for %s %s: %w", file, path, err) + return model.Change{}, fmt.Errorf("fingerprint actual value for %s %s: %w", file, path, err) } displayDesired := renderDisplayValue(format, desiredValue, true) displayActual := renderDisplayValue(format, actualValue, actualPresent) if state == nil { if !actualPresent { - return Change{ - Status: ChangeCreate, + return model.Change{ + Status: model.ChangeCreate, File: file, Path: path, - Reason: ReasonBootstrap, + Reason: model.ReasonBootstrap, Summary: createSummary(path, displayDesired), After: displayDesired, }, nil } if actualFingerprint == desiredFingerprint { - return Change{ - Status: ChangeNoOp, + return model.Change{ + Status: model.ChangeNoOp, File: file, Path: path, - Reason: ReasonAdoption, + Reason: model.ReasonAdoption, Summary: noOpSummary(path), After: displayDesired, }, nil } - return Change{ - Status: ChangeConflict, + return model.Change{ + Status: model.ChangeConflict, File: file, Path: path, - Reason: ReasonAdoption, + Reason: model.ReasonAdoption, Summary: conflictSummary(path, displayActual, displayDesired, true), Before: displayActual, After: displayDesired, @@ -55,8 +58,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri if stateFile == nil { reason := reasonForNewOwnedPath(state, ownerComponentID, desiredVersion) if !actualPresent { - return Change{ - Status: ChangeCreate, + return model.Change{ + Status: model.ChangeCreate, File: file, Path: path, Reason: reason, @@ -66,8 +69,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri } if actualFingerprint == desiredFingerprint { - return Change{ - Status: ChangeNoOp, + return model.Change{ + Status: model.ChangeNoOp, File: file, Path: path, Reason: reason, @@ -76,8 +79,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri }, nil } - return Change{ - Status: ChangeConflict, + return model.Change{ + Status: model.ChangeConflict, File: file, Path: path, Reason: reason, @@ -91,8 +94,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri if !tracked { reason := reasonForNewOwnedPath(state, ownerComponentID, desiredVersion) if !actualPresent { - return Change{ - Status: ChangeCreate, + return model.Change{ + Status: model.ChangeCreate, File: file, Path: path, Reason: reason, @@ -101,8 +104,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri }, nil } if actualFingerprint == desiredFingerprint { - return Change{ - Status: ChangeNoOp, + return model.Change{ + Status: model.ChangeNoOp, File: file, Path: path, Reason: reason, @@ -110,8 +113,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri After: displayDesired, }, nil } - return Change{ - Status: ChangeConflict, + return model.Change{ + Status: model.ChangeConflict, File: file, Path: path, Reason: reason, @@ -122,8 +125,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri } if actualFingerprint == desiredFingerprint { - return Change{ - Status: ChangeNoOp, + return model.Change{ + Status: model.ChangeNoOp, File: file, Path: path, Reason: reasonForDesiredChange(state, ownerComponentID, desiredVersion, lastFingerprint, desiredFingerprint), @@ -133,8 +136,8 @@ func classifyPath(format string, file string, path string, ownerComponentID stri } if actualFingerprint == lastFingerprint { - return Change{ - Status: ChangeUpdate, + return model.Change{ + Status: model.ChangeUpdate, File: file, Path: path, Reason: reasonForDesiredChange(state, ownerComponentID, desiredVersion, lastFingerprint, desiredFingerprint), @@ -145,19 +148,19 @@ func classifyPath(format string, file string, path string, ownerComponentID stri } if desiredFingerprint == lastFingerprint { - return Change{ - Status: ChangeDrift, + return model.Change{ + Status: model.ChangeDrift, File: file, Path: path, - Reason: ReasonDriftCorrection, + Reason: model.ReasonDriftCorrection, Summary: driftSummary(path, displayActual, displayDesired), Before: displayActual, After: displayDesired, }, nil } - return Change{ - Status: ChangeConflict, + return model.Change{ + Status: model.ChangeConflict, File: file, Path: path, Reason: reasonForDesiredChange(state, ownerComponentID, desiredVersion, lastFingerprint, desiredFingerprint), @@ -167,68 +170,68 @@ func classifyPath(format string, file string, path string, ownerComponentID stri }, nil } -func reasonForDesiredChange(state *State, ownerComponentID string, desiredVersion string, lastFingerprint string, desiredFingerprint string) ChangeReason { +func reasonForDesiredChange(state *model.State, ownerComponentID string, desiredVersion string, lastFingerprint string, desiredFingerprint string) model.ChangeReason { if state != nil && componentVersionChanged(state, ownerComponentID, desiredVersion) && lastFingerprint != desiredFingerprint { - return ReasonTemplateUpgrade + return model.ReasonTemplateUpgrade } - return ReasonDesiredState + return model.ReasonDesiredState } -func reasonForNewOwnedPath(state *State, ownerComponentID string, desiredVersion string) ChangeReason { +func reasonForNewOwnedPath(state *model.State, ownerComponentID string, desiredVersion string) model.ChangeReason { if state != nil && componentVersionChanged(state, ownerComponentID, desiredVersion) { - return ReasonTemplateUpgrade + return model.ReasonTemplateUpgrade } - return ReasonDesiredState + return model.ReasonDesiredState } -func componentVersionChanged(state *State, ownerComponentID string, desiredVersion string) bool { +func componentVersionChanged(state *model.State, ownerComponentID string, desiredVersion string) bool { if state == nil || ownerComponentID == "" || desiredVersion == "" { return false } - lastVersion, ok := state.LastAppliedModel.componentVersion(ownerComponentID) + lastVersion, ok := state.LastAppliedModel.ComponentVersion(ownerComponentID) return ok && lastVersion != "" && lastVersion != desiredVersion } -func stateManagedFile(state *State, path string) *ManagedFileState { +func stateManagedFile(state *model.State, path string) *model.ManagedFileState { if state == nil { return nil } - return state.managedFile(path) + return state.ManagedFile(path) } -func summarizeChanges(changes []Change) PlanSummary { - var summary PlanSummary +func summarizeChanges(changes []model.Change) model.PlanSummary { + var summary model.PlanSummary for _, change := range changes { switch change.Status { - case ChangeCreate: + case model.ChangeCreate: summary.Create++ - case ChangeUpdate: + case model.ChangeUpdate: summary.Update++ - case ChangeNoOp: + case model.ChangeNoOp: summary.NoOp++ - case ChangeDrift: + case model.ChangeDrift: summary.Drift++ - case ChangeConflict: + case model.ChangeConflict: summary.Conflict++ } } return summary } -func hasConflict(changes []Change) bool { +func hasConflict(changes []model.Change) bool { for _, change := range changes { - if change.Status == ChangeConflict { + if change.Status == model.ChangeConflict { return true } } return false } -func compactChanges(changes []Change) []Change { - compacted := make([]Change, 0, len(changes)) +func compactChanges(changes []model.Change) []model.Change { + compacted := make([]model.Change, 0, len(changes)) for _, change := range changes { - if change.Status == ChangeNoOp && change.Path == "" { + if change.Status == model.ChangeNoOp && change.Path == "" { continue } compacted = append(compacted, change) @@ -242,10 +245,10 @@ func renderDisplayValue(format string, value any, present bool) string { } switch format { - case FileFormatJSON, FileFormatYAML: - return renderJSONValue(value) + case model.FileFormatJSON, model.FileFormatYAML: + return filefmt.RenderJSONValue(value) default: - rendered, err := renderTomlValue(value) + rendered, err := filefmt.RenderTomlValue(value) if err != nil { return fmt.Sprintf("", err) } @@ -297,7 +300,7 @@ func conflictSummary(path string, before string, after string, adoption bool) st return fmt.Sprintf("conflict in %s: actual %s, desired %s", path, before, after) } -func RenderPlan(plan Plan) string { +func RenderPlan(plan model.Plan) string { lines := []string{ fmt.Sprintf( "Plan: %d to create, %d to update, %d drift, %d conflict, %d no-op", @@ -314,10 +317,10 @@ func RenderPlan(plan Plan) string { lines = append(lines, change.Summary) } - return ensureTrailingNewline(stringsJoin(lines, "\n")) + return filefmt.EnsureTrailingNewline(stringsJoin(lines, "\n")) } -func RenderApply(result ApplyResult) string { +func RenderApply(result model.ApplyResult) string { lines := []string{"Apply complete."} if len(result.Wrote) == 0 { lines = append(lines, "No files changed.") @@ -331,7 +334,7 @@ func RenderApply(result ApplyResult) string { return stringsJoin(lines, "\n") } -func RenderDoctor(report DoctorReport) string { +func RenderDoctor(report model.DoctorReport) string { lines := []string{ "Doctor report:", } diff --git a/internal/moltark/plan_test.go b/internal/engine/plan_test.go similarity index 55% rename from internal/moltark/plan_test.go rename to internal/engine/plan_test.go index bf8c433..d84b363 100644 --- a/internal/moltark/plan_test.go +++ b/internal/engine/plan_test.go @@ -1,22 +1,26 @@ -package moltark +package engine -import "testing" +import ( + "testing" + + "github.com/ophidiarium/moltark/internal/model" +) func TestClassifyPathUsesDesiredStateReasonForNewOwnedPathInCurrentTemplate(t *testing.T) { change, err := classifyPath( - FileFormatTOML, + model.FileFormatTOML, "pyproject.toml", "tool.uv.workspace.members", "component_1", - UVModuleVersion, + model.UVModuleVersion, []string{"packages/api"}, nil, false, - &ManagedFileState{Fingerprints: map[string]string{}}, - &State{ - LastAppliedModel: ModelSummary{ - Components: []ComponentSummary{ - {ID: "component_1", Version: UVModuleVersion}, + &model.ManagedFileState{Fingerprints: map[string]string{}}, + &model.State{ + LastAppliedModel: model.ModelSummary{ + Components: []model.ComponentSummary{ + {ID: "component_1", Version: model.UVModuleVersion}, }, }, }, @@ -25,25 +29,25 @@ func TestClassifyPathUsesDesiredStateReasonForNewOwnedPathInCurrentTemplate(t *t t.Fatalf("classifyPath: %v", err) } - if change.Reason != ReasonDesiredState { + if change.Reason != model.ReasonDesiredState { t.Fatalf("expected desired-state reason, got %q", change.Reason) } } func TestClassifyPathUsesTemplateUpgradeReasonForNewOwnedPathInOlderTemplate(t *testing.T) { change, err := classifyPath( - FileFormatTOML, + model.FileFormatTOML, "pyproject.toml", "tool.uv.workspace.members", "component_1", - UVModuleVersion, + model.UVModuleVersion, []string{"packages/api"}, nil, false, - &ManagedFileState{Fingerprints: map[string]string{}}, - &State{ - LastAppliedModel: ModelSummary{ - Components: []ComponentSummary{ + &model.ManagedFileState{Fingerprints: map[string]string{}}, + &model.State{ + LastAppliedModel: model.ModelSummary{ + Components: []model.ComponentSummary{ {ID: "component_1", Version: "astral/uv/v0"}, }, }, @@ -53,7 +57,7 @@ func TestClassifyPathUsesTemplateUpgradeReasonForNewOwnedPathInOlderTemplate(t * t.Fatalf("classifyPath: %v", err) } - if change.Reason != ReasonTemplateUpgrade { + if change.Reason != model.ReasonTemplateUpgrade { t.Fatalf("expected template-upgrade reason, got %q", change.Reason) } } diff --git a/internal/moltark/resolve.go b/internal/engine/resolve.go similarity index 69% rename from internal/moltark/resolve.go rename to internal/engine/resolve.go index 10f4dd9..72cd48a 100644 --- a/internal/moltark/resolve.go +++ b/internal/engine/resolve.go @@ -1,42 +1,45 @@ -package moltark +package engine import ( "fmt" "sort" + + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" ) type providerInstance struct { componentID string - provider CapabilityProvider + provider model.CapabilityProvider } type factInstance struct { componentID string - fact FactProviderSpec + fact model.FactProviderSpec } -func ResolveModel(model DesiredModel) (ResolvedModel, error) { - managedByPath := map[string]*ManagedFileSpec{} - facts := make([]factInstance, 0, len(model.Components)) - publicFacts := []FactBinding{} - providers := make([]providerInstance, 0, len(model.Components)) - publicProviders := []ProviderBinding{} - synthesisHooks := []ResolvedSynthesisHook{} - bootstrapRequirements := []ResolvedBootstrapRequirement{} - tasks := []ResolvedTask{} - taskSurfaces := []ResolvedTaskSurface{} - - for _, component := range model.Components { +func ResolveModel(desired model.DesiredModel) (model.ResolvedModel, error) { + managedByPath := map[string]*model.ManagedFileSpec{} + facts := make([]factInstance, 0, len(desired.Components)) + publicFacts := []model.FactBinding{} + providers := make([]providerInstance, 0, len(desired.Components)) + publicProviders := []model.ProviderBinding{} + synthesisHooks := []model.ResolvedSynthesisHook{} + bootstrapRequirements := []model.ResolvedBootstrapRequirement{} + tasks := []model.ResolvedTask{} + taskSurfaces := []model.ResolvedTaskSurface{} + + for _, component := range desired.Components { for _, fact := range component.Facts { facts = append(facts, factInstance{ componentID: component.ID, fact: fact, }) - publicFacts = append(publicFacts, FactBinding{ + publicFacts = append(publicFacts, model.FactBinding{ ComponentID: component.ID, Name: fact.Name, ScopeProjectID: fact.ScopeProjectID, - Values: cloneNestedMap(fact.Values), + Values: model.CloneNestedMap(fact.Values), }) } for _, provider := range component.Providers { @@ -44,20 +47,20 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { componentID: component.ID, provider: provider, }) - publicProviders = append(publicProviders, ProviderBinding{ + publicProviders = append(publicProviders, model.ProviderBinding{ ComponentID: component.ID, Capability: provider.Capability, ScopeProjectID: provider.ScopeProjectID, - Attributes: cloneStringMap(provider.Attributes), - Lists: cloneStringSliceMap(provider.Lists), + Attributes: model.CloneStringMap(provider.Attributes), + Lists: model.CloneStringSliceMap(provider.Lists), }) } for _, hook := range component.SynthesisHooks { - project, err := requireProject(model, hook.TargetProjectID) + project, err := requireProject(desired, hook.TargetProjectID) if err != nil { - return ResolvedModel{}, fmt.Errorf("synthesis hook %q: %w", component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("synthesis hook %q: %w", component.ID, err) } - synthesisHooks = append(synthesisHooks, ResolvedSynthesisHook{ + synthesisHooks = append(synthesisHooks, model.ResolvedSynthesisHook{ ComponentID: component.ID, Phase: hook.Phase, TargetProjectID: hook.TargetProjectID, @@ -66,11 +69,11 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { }) } for _, requirement := range component.BootstrapRequirements { - project, err := requireProject(model, requirement.TargetProjectID) + project, err := requireProject(desired, requirement.TargetProjectID) if err != nil { - return ResolvedModel{}, fmt.Errorf("bootstrap requirement %q: %w", component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("bootstrap requirement %q: %w", component.ID, err) } - bootstrapRequirements = append(bootstrapRequirements, ResolvedBootstrapRequirement{ + bootstrapRequirements = append(bootstrapRequirements, model.ResolvedBootstrapRequirement{ ComponentID: component.ID, Tool: requirement.Tool, TargetProjectID: requirement.TargetProjectID, @@ -80,11 +83,11 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { }) } for _, task := range component.Tasks { - project, err := requireProject(model, task.TargetProjectID) + project, err := requireProject(desired, task.TargetProjectID) if err != nil { - return ResolvedModel{}, fmt.Errorf("task %q: %w", component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("task %q: %w", component.ID, err) } - tasks = append(tasks, ResolvedTask{ + tasks = append(tasks, model.ResolvedTask{ ComponentID: component.ID, Name: task.Name, TargetProjectID: task.TargetProjectID, @@ -95,11 +98,11 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { }) } for _, surface := range component.TaskSurfaces { - project, err := requireProject(model, surface.TargetProjectID) + project, err := requireProject(desired, surface.TargetProjectID) if err != nil { - return ResolvedModel{}, fmt.Errorf("task surface %q: %w", component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("task surface %q: %w", component.ID, err) } - taskSurfaces = append(taskSurfaces, ResolvedTaskSurface{ + taskSurfaces = append(taskSurfaces, model.ResolvedTaskSurface{ ComponentID: component.ID, Name: surface.Name, Kind: surface.Kind, @@ -109,69 +112,69 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { } } - for _, component := range model.Components { + for _, component := range desired.Components { for _, file := range component.Files { - resolvedValues, err := resolveFactRefs(model, facts, file.DesiredValues, component.TargetProjectID) + resolvedValues, err := resolveFactRefs(desired, facts, file.DesiredValues, component.TargetProjectID) if err != nil { - return ResolvedModel{}, fmt.Errorf("file intent %q: %w", component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("file intent %q: %w", component.ID, err) } resolvedFile := file resolvedFile.DesiredValues = resolvedValues if err := mergeManagedFileIntent(managedByPath, component.ID, component.Version, resolvedFile); err != nil { - return ResolvedModel{}, err + return model.ResolvedModel{}, err } } } - intentBindings := []IntentBinding{} - for _, component := range model.Components { + intentBindings := []model.IntentBinding{} + for _, component := range desired.Components { for _, intent := range component.RoutedIntents { - provider, err := resolveCapabilityProvider(model, providers, intent.Capability, intent.TargetProjectID) + provider, err := resolveCapabilityProvider(desired, providers, intent.Capability, intent.TargetProjectID) if err != nil { - return ResolvedModel{}, fmt.Errorf("%s %q: %w", intent.Kind, component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("%s %q: %w", intent.Kind, component.ID, err) } - intentBindings = append(intentBindings, IntentBinding{ + intentBindings = append(intentBindings, model.IntentBinding{ ComponentID: component.ID, IntentKind: intent.Kind, Capability: intent.Capability, TargetProjectID: intent.TargetProjectID, - Attributes: cloneStringMap(intent.Attributes), - Lists: cloneStringSliceMap(intent.Lists), + Attributes: model.CloneStringMap(intent.Attributes), + Lists: model.CloneStringSliceMap(intent.Lists), ProviderComponentID: provider.componentID, ProviderScopeProjectID: provider.provider.ScopeProjectID, }) switch intent.Kind { - case IntentWorkspaceMembersRequest: + case model.IntentWorkspaceMembersRequest: if err := applyWorkspaceMembersIntent(managedByPath, component.ID, provider, intent); err != nil { - return ResolvedModel{}, err + return model.ResolvedModel{}, err } - case IntentPythonDependencyRequest: + case model.IntentPythonDependencyRequest: if err := validatePythonDependencyIntent(provider, intent); err != nil { - return ResolvedModel{}, err + return model.ResolvedModel{}, err } default: - return ResolvedModel{}, fmt.Errorf("unsupported routed intent kind %q", intent.Kind) + return model.ResolvedModel{}, fmt.Errorf("unsupported routed intent kind %q", intent.Kind) } } } - triggerBindings := []ResolvedTriggerBinding{} - for _, component := range model.Components { + triggerBindings := []model.ResolvedTriggerBinding{} + for _, component := range desired.Components { for _, binding := range component.TriggerBindings { - project, err := requireProject(model, binding.TargetProjectID) + project, err := requireProject(desired, binding.TargetProjectID) if err != nil { - return ResolvedModel{}, fmt.Errorf("trigger binding %q: %w", component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("trigger binding %q: %w", component.ID, err) } - matches, err := resolveTriggerTasks(model, tasks, binding) + matches, err := resolveTriggerTasks(desired, tasks, binding) if err != nil { - return ResolvedModel{}, fmt.Errorf("trigger binding %q: %w", component.ID, err) + return model.ResolvedModel{}, fmt.Errorf("trigger binding %q: %w", component.ID, err) } for _, task := range matches { - triggerBindings = append(triggerBindings, ResolvedTriggerBinding{ + triggerBindings = append(triggerBindings, model.ResolvedTriggerBinding{ ComponentID: component.ID, Trigger: binding.Trigger, TargetProjectID: binding.TargetProjectID, @@ -187,7 +190,7 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { } } - managedFiles := make([]ManagedFileSpec, 0, len(managedByPath)) + managedFiles := make([]model.ManagedFileSpec, 0, len(managedByPath)) for _, path := range sortedStringKeys(managedByPath) { file := managedByPath[path] file.OwnedPaths = uniqueStringsInOrder(file.OwnedPaths) @@ -278,7 +281,7 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { return triggerBindings[i].ComponentID < triggerBindings[j].ComponentID }) - return ResolvedModel{ + return model.ResolvedModel{ ManagedFiles: managedFiles, Facts: publicFacts, Providers: publicProviders, @@ -291,23 +294,23 @@ func ResolveModel(model DesiredModel) (ResolvedModel, error) { }, nil } -func mergeManagedFileIntent(managedByPath map[string]*ManagedFileSpec, componentID string, componentVersion string, file StructuredFileSpec) error { +func mergeManagedFileIntent(managedByPath map[string]*model.ManagedFileSpec, componentID string, componentVersion string, file model.StructuredFileSpec) error { for _, ownedPath := range file.OwnedPaths { - if _, err := requireStructuredValue(file.DesiredValues, file.Format, ownedPath); err != nil { + if _, err := filefmt.RequireStructuredValue(file.DesiredValues, file.Format, ownedPath); err != nil { return fmt.Errorf("file %q: %w", file.Path, err) } } current, ok := managedByPath[file.Path] if !ok { - current = &ManagedFileSpec{ + current = &model.ManagedFileSpec{ Path: file.Path, Format: file.Format, OwnedPaths: append([]string(nil), file.OwnedPaths...), OwnedPathOwners: map[string]string{}, OwnedPathVersions: map[string]string{}, UserManagedPaths: append([]string(nil), file.UserManagedPaths...), - DesiredValues: cloneNestedMap(file.DesiredValues), + DesiredValues: model.CloneNestedMap(file.DesiredValues), SourceComponents: []string{componentID}, } for _, ownedPath := range file.OwnedPaths { @@ -325,14 +328,14 @@ func mergeManagedFileIntent(managedByPath map[string]*ManagedFileSpec, component } for _, ownedPath := range file.OwnedPaths { - value, err := requireStructuredValue(file.DesiredValues, file.Format, ownedPath) + value, err := filefmt.RequireStructuredValue(file.DesiredValues, file.Format, ownedPath) if err != nil { return fmt.Errorf("file %q: %w", file.Path, err) } if existingOwner, ok := current.OwnedPathOwners[ownedPath]; ok && existingOwner != componentID { return fmt.Errorf("file %q has conflicting ownership for %s from components %q and %q", file.Path, ownedPath, existingOwner, componentID) } - if existingValue, ok := lookupStructuredValue(current.DesiredValues, current.Format, ownedPath); ok { + if existingValue, ok := filefmt.LookupStructuredValue(current.DesiredValues, current.Format, ownedPath); ok { existingFingerprint, err := fingerprintValue(existingValue, true) if err != nil { return fmt.Errorf("file %q compare %s: %w", file.Path, ownedPath, err) @@ -345,7 +348,7 @@ func mergeManagedFileIntent(managedByPath map[string]*ManagedFileSpec, component return fmt.Errorf("file %q has conflicting desired values for %s", file.Path, ownedPath) } } - setStructuredValue(current.DesiredValues, current.Format, ownedPath, value) + filefmt.SetStructuredValue(current.DesiredValues, current.Format, ownedPath, value) current.OwnedPaths = append(current.OwnedPaths, ownedPath) current.OwnedPathOwners[ownedPath] = componentID if componentVersion != "" { @@ -361,8 +364,8 @@ func mergeManagedFileIntent(managedByPath map[string]*ManagedFileSpec, component return nil } -func resolveCapabilityProvider(model DesiredModel, providers []providerInstance, capability string, projectID string) (providerInstance, error) { - scopeChain, err := projectScopeChain(model, projectID) +func resolveCapabilityProvider(desired model.DesiredModel, providers []providerInstance, capability string, projectID string) (providerInstance, error) { + scopeChain, err := projectScopeChain(desired, projectID) if err != nil { return providerInstance{}, err } @@ -398,8 +401,8 @@ func resolveCapabilityProvider(model DesiredModel, providers []providerInstance, return providerInstance{}, fmt.Errorf("no provider for capability %q near project %q", capability, projectID) } -func resolveFactProvider(model DesiredModel, facts []factInstance, name string, projectID string) (factInstance, error) { - scopeChain, err := projectScopeChain(model, projectID) +func resolveFactProvider(desired model.DesiredModel, facts []factInstance, name string, projectID string) (factInstance, error) { + scopeChain, err := projectScopeChain(desired, projectID) if err != nil { return factInstance{}, err } @@ -435,8 +438,8 @@ func resolveFactProvider(model DesiredModel, facts []factInstance, name string, return factInstance{}, fmt.Errorf("no fact %q near project %q", name, projectID) } -func resolveFactRefs(model DesiredModel, facts []factInstance, values map[string]any, defaultProjectID string) (map[string]any, error) { - resolved, err := resolveFactRefsValue(model, facts, values, defaultProjectID) +func resolveFactRefs(desired model.DesiredModel, facts []factInstance, values map[string]any, defaultProjectID string) (map[string]any, error) { + resolved, err := resolveFactRefsValue(desired, facts, values, defaultProjectID) if err != nil { return nil, err } @@ -447,9 +450,9 @@ func resolveFactRefs(model DesiredModel, facts []factInstance, values map[string return root, nil } -func resolveFactRefsValue(model DesiredModel, facts []factInstance, value any, defaultProjectID string) (any, error) { +func resolveFactRefsValue(desired model.DesiredModel, facts []factInstance, value any, defaultProjectID string) (any, error) { switch typed := value.(type) { - case FactValueRef: + case model.FactValueRef: projectID := typed.TargetProjectID if projectID == "" { projectID = defaultProjectID @@ -457,19 +460,19 @@ func resolveFactRefsValue(model DesiredModel, facts []factInstance, value any, d if projectID == "" { return nil, fmt.Errorf("fact reference %q has no target project", typed.Name) } - fact, err := resolveFactProvider(model, facts, typed.Name, projectID) + fact, err := resolveFactProvider(desired, facts, typed.Name, projectID) if err != nil { return nil, err } - resolved, ok := lookupStructuredValue(fact.fact.Values, FileFormatTOML, typed.Path) + resolved, ok := filefmt.LookupStructuredValue(fact.fact.Values, model.FileFormatTOML, typed.Path) if !ok { return nil, fmt.Errorf("fact %q on component %q does not provide path %q", typed.Name, fact.componentID, typed.Path) } - return cloneStructuredValue(resolved), nil + return model.CloneStructuredValue(resolved), nil case map[string]any: resolved := make(map[string]any, len(typed)) for key, nested := range typed { - value, err := resolveFactRefsValue(model, facts, nested, defaultProjectID) + value, err := resolveFactRefsValue(desired, facts, nested, defaultProjectID) if err != nil { return nil, err } @@ -479,7 +482,7 @@ func resolveFactRefsValue(model DesiredModel, facts []factInstance, value any, d case []any: resolved := make([]any, 0, len(typed)) for _, nested := range typed { - value, err := resolveFactRefsValue(model, facts, nested, defaultProjectID) + value, err := resolveFactRefsValue(desired, facts, nested, defaultProjectID) if err != nil { return nil, err } @@ -491,8 +494,8 @@ func resolveFactRefsValue(model DesiredModel, facts []factInstance, value any, d } } -func projectScopeChain(model DesiredModel, projectID string) ([]string, error) { - project, err := requireProject(model, projectID) +func projectScopeChain(desired model.DesiredModel, projectID string) ([]string, error) { + project, err := requireProject(desired, projectID) if err != nil { return nil, err } @@ -510,7 +513,7 @@ func projectScopeChain(model DesiredModel, projectID string) ([]string, error) { break } parentID := current.ParentID - current = model.projectByID(parentID) + current = desired.ProjectByID(parentID) if current == nil { return nil, fmt.Errorf("project parent %q is not declared", parentID) } @@ -518,26 +521,26 @@ func projectScopeChain(model DesiredModel, projectID string) ([]string, error) { return chain, nil } -func applyWorkspaceMembersIntent(managedByPath map[string]*ManagedFileSpec, componentID string, provider providerInstance, intent RoutedIntentSpec) error { - filePath := provider.provider.Attributes[ProviderAttrFilePath] - ownedPath := provider.provider.Attributes[ProviderAttrOwnedPath] +func applyWorkspaceMembersIntent(managedByPath map[string]*model.ManagedFileSpec, componentID string, provider providerInstance, intent model.RoutedIntentSpec) error { + filePath := provider.provider.Attributes[model.ProviderAttrFilePath] + ownedPath := provider.provider.Attributes[model.ProviderAttrOwnedPath] if filePath == "" || ownedPath == "" { return fmt.Errorf("workspace manager provider on component %q is missing file_path or owned_path", provider.componentID) } current, ok := managedByPath[filePath] if !ok { - current = &ManagedFileSpec{ + current = &model.ManagedFileSpec{ Path: filePath, - Format: FileFormatTOML, + Format: model.FileFormatTOML, DesiredValues: map[string]any{}, SourceComponents: []string{}, } managedByPath[filePath] = current } - memberPaths := append([]string(nil), intent.Lists[IntentListMemberPaths]...) - if existingValue, ok := lookupStructuredValue(current.DesiredValues, FileFormatTOML, ownedPath); ok { + memberPaths := append([]string(nil), intent.Lists[model.IntentListMemberPaths]...) + if existingValue, ok := filefmt.LookupStructuredValue(current.DesiredValues, model.FileFormatTOML, ownedPath); ok { existingFingerprint, err := fingerprintValue(existingValue, true) if err != nil { return fmt.Errorf("file %q compare %s: %w", filePath, ownedPath, err) @@ -551,47 +554,47 @@ func applyWorkspaceMembersIntent(managedByPath map[string]*ManagedFileSpec, comp } } - setStructuredValue(current.DesiredValues, FileFormatTOML, ownedPath, memberPaths) + filefmt.SetStructuredValue(current.DesiredValues, model.FileFormatTOML, ownedPath, memberPaths) current.OwnedPaths = append(current.OwnedPaths, ownedPath) current.SourceComponents = append(current.SourceComponents, componentID, provider.componentID) if current.Format == "" { - current.Format = FileFormatTOML + current.Format = model.FileFormatTOML } return nil } -func validatePythonDependencyIntent(provider providerInstance, intent RoutedIntentSpec) error { - requirement := intent.Attributes[IntentAttrRequirement] +func validatePythonDependencyIntent(provider providerInstance, intent model.RoutedIntentSpec) error { + requirement := intent.Attributes[model.IntentAttrRequirement] if requirement == "" { return fmt.Errorf("python package manager request is missing requirement") } - dependencyPath := provider.provider.Attributes[ProviderAttrDependencyPath] + dependencyPath := provider.provider.Attributes[model.ProviderAttrDependencyPath] if dependencyPath == "" { return fmt.Errorf("python package manager provider on component %q is missing dependency_path", provider.componentID) } - artifactKinds := provider.provider.Lists[ProviderListArtifactKinds] + artifactKinds := provider.provider.Lists[model.ProviderListArtifactKinds] if len(artifactKinds) == 0 { return fmt.Errorf("python package manager provider on component %q is missing artifact kinds", provider.componentID) } - if !stringSliceContains(artifactKinds, ArtifactKindPyPI) { - return fmt.Errorf("python package manager provider on component %q does not accept %q artifacts", provider.componentID, ArtifactKindPyPI) + if !stringSliceContains(artifactKinds, model.ArtifactKindPyPI) { + return fmt.Errorf("python package manager provider on component %q does not accept %q artifacts", provider.componentID, model.ArtifactKindPyPI) } return nil } -func resolveTriggerTasks(model DesiredModel, tasks []ResolvedTask, binding TriggerBindingSpec) ([]ResolvedTask, error) { +func resolveTriggerTasks(desired model.DesiredModel, tasks []model.ResolvedTask, binding model.TriggerBindingSpec) ([]model.ResolvedTask, error) { if len(binding.MatchNames) == 0 && len(binding.MatchTags) == 0 { return nil, fmt.Errorf("must specify match_names or match_tags") } - matches := []ResolvedTask{} + matches := []model.ResolvedTask{} for _, task := range tasks { - withinScope, err := projectWithinSubtree(model, task.TargetProjectID, binding.TargetProjectID) + withinScope, err := projectWithinSubtree(desired, task.TargetProjectID, binding.TargetProjectID) if err != nil { return nil, err } @@ -611,7 +614,7 @@ func resolveTriggerTasks(model DesiredModel, tasks []ResolvedTask, binding Trigg return matches, nil } -func taskMatches(binding TriggerBindingSpec, task ResolvedTask) bool { +func taskMatches(binding model.TriggerBindingSpec, task model.ResolvedTask) bool { if len(binding.MatchNames) > 0 && !stringSliceContains(binding.MatchNames, task.Name) { return false } @@ -621,8 +624,8 @@ func taskMatches(binding TriggerBindingSpec, task ResolvedTask) bool { return true } -func projectWithinSubtree(model DesiredModel, candidateProjectID string, ancestorProjectID string) (bool, error) { - current, err := requireProject(model, candidateProjectID) +func projectWithinSubtree(desired model.DesiredModel, candidateProjectID string, ancestorProjectID string) (bool, error) { + current, err := requireProject(desired, candidateProjectID) if err != nil { return false, err } @@ -635,7 +638,7 @@ func projectWithinSubtree(model DesiredModel, candidateProjectID string, ancesto return false, nil } parentID := current.ParentID - current = model.projectByID(parentID) + current = desired.ProjectByID(parentID) if current == nil { return false, fmt.Errorf("project parent %q is not declared", parentID) } @@ -644,65 +647,14 @@ func projectWithinSubtree(model DesiredModel, candidateProjectID string, ancesto return false, nil } -func requireProject(model DesiredModel, projectID string) (*ProjectSpec, error) { - project := model.projectByID(projectID) +func requireProject(desired model.DesiredModel, projectID string) (*model.ProjectSpec, error) { + project := desired.ProjectByID(projectID) if project == nil { return nil, fmt.Errorf("project %q is not declared", projectID) } return project, nil } -func cloneNestedMap(values map[string]any) map[string]any { - cloned := map[string]any{} - for key, value := range values { - cloned[key] = cloneStructuredValue(value) - } - return cloned -} - -func cloneSlice(values []any) []any { - cloned := make([]any, 0, len(values)) - for _, value := range values { - cloned = append(cloned, cloneStructuredValue(value)) - } - return cloned -} - -func cloneStructuredValue(value any) any { - switch typed := value.(type) { - case map[string]any: - return cloneNestedMap(typed) - case []string: - return append([]string(nil), typed...) - case []any: - return cloneSlice(typed) - default: - return value - } -} - -func cloneStringMap(values map[string]string) map[string]string { - if len(values) == 0 { - return nil - } - cloned := make(map[string]string, len(values)) - for key, value := range values { - cloned[key] = value - } - return cloned -} - -func cloneStringSliceMap(values map[string][]string) map[string][]string { - if len(values) == 0 { - return nil - } - cloned := make(map[string][]string, len(values)) - for key, value := range values { - cloned[key] = append([]string(nil), value...) - } - return cloned -} - func sortedStringKeys[T any](values map[string]T) []string { keys := make([]string, 0, len(values)) for key := range values { diff --git a/internal/engine/resolve_test.go b/internal/engine/resolve_test.go new file mode 100644 index 0000000..be9b9f1 --- /dev/null +++ b/internal/engine/resolve_test.go @@ -0,0 +1,181 @@ +package engine + +import ( + "strings" + "testing" + + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" +) + +func TestResolveModelResolvesFactReferencesInStructuredFiles(t *testing.T) { + desired := model.DesiredModel{ + Projects: []model.ProjectSpec{ + { + ID: "app", + Kind: model.ProjectKindGeneric, + Name: "app", + Path: ".", + EffectivePath: ".", + }, + }, + Components: []model.ComponentSpec{ + { + ID: "component_fact", + Kind: "fact", + Module: model.ModuleSourceCore, + TargetProjectID: "app", + Facts: []model.FactProviderSpec{ + { + Name: model.FactLanguageGo, + ScopeProjectID: "app", + Values: map[string]any{ + "version": "1.24", + }, + }, + }, + }, + { + ID: "component_file", + Kind: "toml_file", + Module: model.ModuleSourceCore, + TargetProjectID: "app", + Files: []model.StructuredFileSpec{ + { + Path: ".golangci.toml", + Format: model.FileFormatTOML, + OwnedPaths: []string{"run.go"}, + DesiredValues: map[string]any{ + "run": map[string]any{ + "go": model.FactValueRef{ + TargetProjectID: "app", + Name: model.FactLanguageGo, + Path: "version", + }, + }, + }, + }, + }, + }, + }, + } + + resolved, err := ResolveModel(desired) + if err != nil { + t.Fatalf("ResolveModel: %v", err) + } + + if len(resolved.ManagedFiles) != 1 { + t.Fatalf("expected 1 managed file, got %d", len(resolved.ManagedFiles)) + } + + got, ok := filefmt.LookupStructuredValue(resolved.ManagedFiles[0].DesiredValues, model.FileFormatTOML, "run.go") + if !ok { + t.Fatalf("expected run.go to be resolved") + } + if got != "1.24" { + t.Fatalf("unexpected fact value: got %#v want %q", got, "1.24") + } +} + +func TestResolveModelRejectsAmbiguousFactsAtSameScope(t *testing.T) { + desired := model.DesiredModel{ + Projects: []model.ProjectSpec{ + { + ID: "app", + Kind: model.ProjectKindGeneric, + Name: "app", + Path: ".", + EffectivePath: ".", + }, + }, + Components: []model.ComponentSpec{ + { + ID: "component_fact_1", + Kind: "fact", + Module: model.ModuleSourceCore, + TargetProjectID: "app", + Facts: []model.FactProviderSpec{ + { + Name: model.FactLanguageGo, + ScopeProjectID: "app", + Values: map[string]any{"version": "1.24"}, + }, + }, + }, + { + ID: "component_fact_2", + Kind: "fact", + Module: model.ModuleSourceCore, + TargetProjectID: "app", + Facts: []model.FactProviderSpec{ + { + Name: model.FactLanguageGo, + ScopeProjectID: "app", + Values: map[string]any{"version": "1.25"}, + }, + }, + }, + { + ID: "component_file", + Kind: "toml_file", + Module: model.ModuleSourceCore, + TargetProjectID: "app", + Files: []model.StructuredFileSpec{ + { + Path: ".golangci.toml", + Format: model.FileFormatTOML, + OwnedPaths: []string{"run.go"}, + DesiredValues: map[string]any{ + "run": map[string]any{ + "go": model.FactValueRef{ + TargetProjectID: "app", + Name: model.FactLanguageGo, + Path: "version", + }, + }, + }, + }, + }, + }, + }, + } + + if _, err := ResolveModel(desired); err == nil { + t.Fatal("expected ambiguous fact resolution to fail") + } +} + +func TestProjectScopeChainReportsActualMissingAncestor(t *testing.T) { + desired := model.DesiredModel{ + Projects: []model.ProjectSpec{ + {ID: "leaf", ParentID: "mid"}, + {ID: "mid", ParentID: "missing"}, + }, + } + + _, err := projectScopeChain(desired, "leaf") + if err == nil { + t.Fatal("expected missing ancestor to fail") + } + if !strings.Contains(err.Error(), `project parent "missing" is not declared`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProjectWithinSubtreeReportsActualMissingAncestor(t *testing.T) { + desired := model.DesiredModel{ + Projects: []model.ProjectSpec{ + {ID: "leaf", ParentID: "mid"}, + {ID: "mid", ParentID: "missing"}, + }, + } + + _, err := projectWithinSubtree(desired, "leaf", "root") + if err == nil { + t.Fatal("expected missing ancestor to fail") + } + if !strings.Contains(err.Error(), `project parent "missing" is not declared`) { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/moltark/service.go b/internal/engine/service.go similarity index 54% rename from internal/moltark/service.go rename to internal/engine/service.go index ce67ebe..116587b 100644 --- a/internal/moltark/service.go +++ b/internal/engine/service.go @@ -1,10 +1,13 @@ -package moltark +package engine import ( "fmt" "os" "path/filepath" "reflect" + + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" ) type Service struct{} @@ -13,27 +16,27 @@ func NewService() Service { return Service{} } -func (s Service) Plan(root string) (Plan, error) { +func (s Service) Plan(root string) (model.Plan, error) { pipeline, err := s.BuildPipeline(root) if err != nil { - return Plan{}, err + return model.Plan{}, err } return pipeline.PlanResult(), nil } -func (s Service) Apply(root string, plan Plan) (ApplyResult, error) { +func (s Service) Apply(root string, plan model.Plan) (model.ApplyResult, error) { if plan.HasConflicts() { - return ApplyResult{}, fmt.Errorf("cannot apply a plan with conflicts") + return model.ApplyResult{}, fmt.Errorf("cannot apply a plan with conflicts") } pipeline, err := s.BuildPipeline(root) if err != nil { - return ApplyResult{}, err + return model.ApplyResult{}, err } freshPlan := pipeline.PlanResult() if !sameApplyIntent(plan, freshPlan) { - return ApplyResult{}, fmt.Errorf("repository changed since planning; rerun `moltark plan` and review the updated changes") + return model.ApplyResult{}, fmt.Errorf("repository changed since planning; rerun `moltark plan` and review the updated changes") } plan = freshPlan @@ -42,39 +45,39 @@ func (s Service) Apply(root string, plan Plan) (ApplyResult, error) { doc := pipeline.fileDocs[managedFile.Path] body, err := mutateStructuredFile(doc.Raw, managedFile) if err != nil { - return ApplyResult{}, err + return model.ApplyResult{}, err } - if body == ensureTrailingNewline(doc.Raw) { + if body == filefmt.EnsureTrailingNewline(doc.Raw) { continue } if err := writeRepoFile(root, managedFile.Path, body); err != nil { - return ApplyResult{}, err + return model.ApplyResult{}, err } wrote = append(wrote, managedFile.Path) } - gitattributesBody := mutateGitattributes(pipeline.gitattributesRaw) - if gitattributesBody != ensureTrailingNewline(pipeline.gitattributesRaw) { - if err := writeRepoFile(root, GitattributesFileName, gitattributesBody); err != nil { - return ApplyResult{}, err + gitattributesBody := filefmt.MutateGitattributes(pipeline.gitattributesRaw) + if gitattributesBody != filefmt.EnsureTrailingNewline(pipeline.gitattributesRaw) { + if err := writeRepoFile(root, model.GitattributesFileName, gitattributesBody); err != nil { + return model.ApplyResult{}, err } - wrote = append(wrote, GitattributesFileName) + wrote = append(wrote, model.GitattributesFileName) } if pipeline.nextStateRaw != pipeline.stateRaw { if err := writeStateFile(root, pipeline.nextStateRaw); err != nil { - return ApplyResult{}, err + return model.ApplyResult{}, err } - wrote = append(wrote, filepath.ToSlash(filepath.Join(StateDirName, StateFileName))) + wrote = append(wrote, filepath.ToSlash(filepath.Join(model.StateDirName, model.StateFileName))) } - return ApplyResult{ + return model.ApplyResult{ Plan: plan, Wrote: wrote, }, nil } -func sameApplyIntent(left Plan, right Plan) bool { +func sameApplyIntent(left model.Plan, right model.Plan) bool { return reflect.DeepEqual(left.Desired, right.Desired) && reflect.DeepEqual(left.Resolved, right.Resolved) && reflect.DeepEqual(left.NextState, right.NextState) && @@ -82,10 +85,10 @@ func sameApplyIntent(left Plan, right Plan) bool { reflect.DeepEqual(actionableSummary(left.Summary), actionableSummary(right.Summary)) } -func actionableChanges(changes []Change) []Change { - filtered := make([]Change, 0, len(changes)) +func actionableChanges(changes []model.Change) []model.Change { + filtered := make([]model.Change, 0, len(changes)) for _, change := range changes { - if change.Status == ChangeNoOp { + if change.Status == model.ChangeNoOp { continue } filtered = append(filtered, change) @@ -93,27 +96,27 @@ func actionableChanges(changes []Change) []Change { return filtered } -func actionableSummary(summary PlanSummary) PlanSummary { +func actionableSummary(summary model.PlanSummary) model.PlanSummary { summary.NoOp = 0 return summary } -func reasonForStateUpdate(previous *State, next *State) ChangeReason { +func reasonForStateUpdate(previous *model.State, next *model.State) model.ChangeReason { if previous == nil || next == nil { - return ReasonBootstrap + return model.ReasonBootstrap } if modelHasTemplateUpgrade(previous.LastAppliedModel, next.LastAppliedModel) { - return ReasonTemplateUpgrade + return model.ReasonTemplateUpgrade } - return ReasonDesiredState + return model.ReasonDesiredState } -func modelHasTemplateUpgrade(previous ModelSummary, next ModelSummary) bool { +func modelHasTemplateUpgrade(previous model.ModelSummary, next model.ModelSummary) bool { for _, component := range next.Components { if component.Version == "" { continue } - lastVersion, ok := previous.componentVersion(component.ID) + lastVersion, ok := previous.ComponentVersion(component.ID) if ok && lastVersion != "" && lastVersion != component.Version { return true } @@ -129,10 +132,10 @@ func (s Service) Show(root string) (Pipeline, error) { return pipeline, nil } -func (s Service) Doctor(root string) (DoctorReport, error) { +func (s Service) Doctor(root string) (model.DoctorReport, error) { pipeline, err := s.BuildPipeline(root) if err != nil { - return DoctorReport{}, err + return model.DoctorReport{}, err } plan := pipeline.PlanResult() @@ -150,20 +153,20 @@ func (s Service) Doctor(root string) (DoctorReport, error) { messages = append(messages, "repository is healthy") } - return DoctorReport{ + return model.DoctorReport{ Plan: plan, HasIssues: hasIssues, Messages: messages, }, nil } -func loadStructuredDocument(path string, format string) (fileDocument, error) { +func loadStructuredDocument(path string, format string) (model.FileDocument, error) { raw, exists, err := readOptionalFile(path) if err != nil { - return fileDocument{}, err + return model.FileDocument{}, err } - doc := fileDocument{ + doc := model.FileDocument{ Raw: raw, Exists: exists, Values: map[string]any{}, @@ -173,38 +176,38 @@ func loadStructuredDocument(path string, format string) (fileDocument, error) { } switch format { - case FileFormatTOML: + case model.FileFormatTOML: values := map[string]any{} - if err := decodeToml([]byte(raw), &values); err != nil { - return fileDocument{}, err + if err := filefmt.DecodeToml([]byte(raw), &values); err != nil { + return model.FileDocument{}, err } doc.Values = values - case FileFormatJSON: - values, err := parseJSONValues([]byte(raw)) + case model.FileFormatJSON: + values, err := filefmt.ParseJSONValues([]byte(raw)) if err != nil { - return fileDocument{}, err + return model.FileDocument{}, err } doc.Values = values - case FileFormatYAML: - values, err := parseYAMLValues([]byte(raw)) + case model.FileFormatYAML: + values, err := filefmt.ParseYAMLValues([]byte(raw)) if err != nil { - return fileDocument{}, err + return model.FileDocument{}, err } doc.Values = values default: - return fileDocument{}, fmt.Errorf("unsupported file format %q", format) + return model.FileDocument{}, fmt.Errorf("unsupported file format %q", format) } return doc, nil } -func mutateStructuredFile(raw string, file ManagedFileSpec) (string, error) { +func mutateStructuredFile(raw string, file model.ManagedFileSpec) (string, error) { switch file.Format { - case FileFormatTOML: - return mutateTOMLFile(raw, file.DesiredValues, file.OwnedPaths) - case FileFormatJSON: - return mutateJSONFile(raw, file.DesiredValues, file.OwnedPaths) - case FileFormatYAML: - return mutateYAMLFile(raw, file.DesiredValues, file.OwnedPaths) + case model.FileFormatTOML: + return filefmt.MutateTOMLFile(raw, file.DesiredValues, file.OwnedPaths) + case model.FileFormatJSON: + return filefmt.MutateJSONFile(raw, file.DesiredValues, file.OwnedPaths) + case model.FileFormatYAML: + return filefmt.MutateYAMLFile(raw, file.DesiredValues, file.OwnedPaths) default: return "", fmt.Errorf("unsupported file format %q", file.Format) } diff --git a/internal/moltark/service_test.go b/internal/engine/service_test.go similarity index 92% rename from internal/moltark/service_test.go rename to internal/engine/service_test.go index bb52bfc..c07865c 100644 --- a/internal/moltark/service_test.go +++ b/internal/engine/service_test.go @@ -1,4 +1,4 @@ -package moltark +package engine import ( "os" @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/ophidiarium/moltark/internal/model" "github.com/ophidiarium/moltark/internal/testrepo" ) @@ -24,7 +25,7 @@ func TestApplyReplansAgainstFreshFileBodies(t *testing.T) { t.Fatalf("plan: %v", err) } - pyprojectPath := filepath.Join(root, PyprojectFileName) + pyprojectPath := filepath.Join(root, model.PyprojectFileName) raw, err := os.ReadFile(pyprojectPath) if err != nil { t.Fatalf("read pyproject: %v", err) diff --git a/internal/moltark/state.go b/internal/engine/state.go similarity index 58% rename from internal/moltark/state.go rename to internal/engine/state.go index 5b49241..91a7aab 100644 --- a/internal/moltark/state.go +++ b/internal/engine/state.go @@ -1,4 +1,4 @@ -package moltark +package engine import ( "bytes" @@ -8,12 +8,19 @@ import ( "fmt" "os" "path/filepath" + + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" ) type stateDocument struct { Exists bool Raw string - State *State + State *model.State +} + +func statePath(root string) string { + return filepath.Join(root, model.StateDirName, model.StateFileName) } func loadState(root string) (stateDocument, error) { @@ -26,16 +33,16 @@ func loadState(root string) (stateDocument, error) { return stateDocument{}, fmt.Errorf("read %s: %w", path, err) } - var state State + var state model.State if err := json.Unmarshal(raw, &state); err != nil { return stateDocument{}, fmt.Errorf("parse %s: %w", path, err) } - if state.SchemaVersion != SchemaVersion { + if state.SchemaVersion != model.SchemaVersion { return stateDocument{}, fmt.Errorf( "parse %s: unsupported schema_version %d (expected %d)", path, state.SchemaVersion, - SchemaVersion, + model.SchemaVersion, ) } @@ -46,20 +53,6 @@ func loadState(root string) (stateDocument, error) { }, nil } -func (s *State) managedFile(path string) *ManagedFileState { - if s == nil { - return nil - } - - for i := range s.ManagedFiles { - if s.ManagedFiles[i].Path == path { - return &s.ManagedFiles[i] - } - } - - return nil -} - func fingerprintValue(value any, present bool) (string, error) { payload := map[string]any{ "present": present, @@ -75,19 +68,19 @@ func fingerprintValue(value any, present bool) (string, error) { return hex.EncodeToString(sum[:]), nil } -func buildState(model DesiredModel, resolved ResolvedModel) (State, error) { - managedFiles := make([]ManagedFileState, 0, len(resolved.ManagedFiles)+1) +func buildState(desired model.DesiredModel, resolved model.ResolvedModel) (model.State, error) { + managedFiles := make([]model.ManagedFileState, 0, len(resolved.ManagedFiles)+1) for _, file := range resolved.ManagedFiles { fingerprints := make(map[string]string, len(file.OwnedPaths)) versions := make(map[string]string, len(file.OwnedPaths)) for _, ownedPath := range file.OwnedPaths { - value, err := requireStructuredValue(file.DesiredValues, file.Format, ownedPath) + value, err := filefmt.RequireStructuredValue(file.DesiredValues, file.Format, ownedPath) if err != nil { - return State{}, fmt.Errorf("build state for %s %s: %w", file.Path, ownedPath, err) + return model.State{}, fmt.Errorf("build state for %s %s: %w", file.Path, ownedPath, err) } fingerprint, err := fingerprintValue(value, true) if err != nil { - return State{}, fmt.Errorf("build state for %s %s: %w", file.Path, ownedPath, err) + return model.State{}, fmt.Errorf("build state for %s %s: %w", file.Path, ownedPath, err) } fingerprints[ownedPath] = fingerprint if version := file.OwnedPathVersions[ownedPath]; version != "" { @@ -95,7 +88,7 @@ func buildState(model DesiredModel, resolved ResolvedModel) (State, error) { } } - managedFiles = append(managedFiles, ManagedFileState{ + managedFiles = append(managedFiles, model.ManagedFileState{ Path: file.Path, OwnedPaths: append([]string(nil), file.OwnedPaths...), OwnedPathVersions: versions, @@ -103,44 +96,44 @@ func buildState(model DesiredModel, resolved ResolvedModel) (State, error) { }) } - gitattributesBlock := managedGitattributesBlock() + gitattributesBlock := filefmt.ManagedGitattributesBlock() gitattributesFingerprint, err := fingerprintValue(gitattributesBlock, true) if err != nil { - return State{}, fmt.Errorf("build state for %s moltark.block: %w", GitattributesFileName, err) + return model.State{}, fmt.Errorf("build state for %s moltark.block: %w", model.GitattributesFileName, err) } - managedFiles = append(managedFiles, ManagedFileState{ - Path: GitattributesFileName, + managedFiles = append(managedFiles, model.ManagedFileState{ + Path: model.GitattributesFileName, OwnedPaths: []string{"moltark.block"}, Fingerprints: map[string]string{ "moltark.block": gitattributesFingerprint, }, }) - return State{ - SchemaVersion: SchemaVersion, + return model.State{ + SchemaVersion: model.SchemaVersion, ManagedFiles: managedFiles, - LastAppliedModel: summarizeModel(model), + LastAppliedModel: summarizeModel(desired), }, nil } -func summarizeModel(model DesiredModel) ModelSummary { - summary := ModelSummary{ - Projects: make([]ProjectSummary, 0, len(model.Projects)), - Components: make([]ComponentSummary, 0, len(model.Components)), +func summarizeModel(desired model.DesiredModel) model.ModelSummary { + summary := model.ModelSummary{ + Projects: make([]model.ProjectSummary, 0, len(desired.Projects)), + Components: make([]model.ComponentSummary, 0, len(desired.Components)), } - for _, project := range model.Projects { - summary.Projects = append(summary.Projects, ProjectSummary{ + for _, project := range desired.Projects { + summary.Projects = append(summary.Projects, model.ProjectSummary{ ID: project.ID, Kind: project.Kind, Name: project.Name, Path: project.Path, EffectivePath: project.EffectivePath, - Attributes: cloneStringMap(project.Attributes), + Attributes: model.CloneStringMap(project.Attributes), ParentID: project.ParentID, }) } - for _, component := range model.Components { - summary.Components = append(summary.Components, ComponentSummary{ + for _, component := range desired.Components { + summary.Components = append(summary.Components, model.ComponentSummary{ ID: component.ID, Kind: component.Kind, Module: component.Module, @@ -151,7 +144,7 @@ func summarizeModel(model DesiredModel) ModelSummary { return summary } -func renderState(state State) (string, error) { +func renderState(state model.State) (string, error) { var buffer bytes.Buffer encoder := json.NewEncoder(&buffer) encoder.SetEscapeHTML(false) @@ -164,10 +157,10 @@ func renderState(state State) (string, error) { } func writeStateFile(root string, body string) error { - dir := filepath.Join(root, StateDirName) + dir := filepath.Join(root, model.StateDirName) if err := os.MkdirAll(dir, 0o755); err != nil { return err } - return os.WriteFile(filepath.Join(dir, StateFileName), []byte(body), 0o644) + return os.WriteFile(filepath.Join(dir, model.StateFileName), []byte(body), 0o644) } diff --git a/internal/moltark/state_test.go b/internal/engine/state_test.go similarity index 86% rename from internal/moltark/state_test.go rename to internal/engine/state_test.go index 676db74..8d5c57d 100644 --- a/internal/moltark/state_test.go +++ b/internal/engine/state_test.go @@ -1,15 +1,17 @@ -package moltark +package engine import ( "os" "path/filepath" "strings" "testing" + + "github.com/ophidiarium/moltark/internal/model" ) func TestLoadStateRejectsIncompatibleSchemaVersion(t *testing.T) { root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, StateDirName), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(root, model.StateDirName), 0o755); err != nil { t.Fatalf("mkdir state dir: %v", err) } body := `{ diff --git a/internal/filefmt/BUILD.bazel b/internal/filefmt/BUILD.bazel new file mode 100644 index 0000000..e03deb3 --- /dev/null +++ b/internal/filefmt/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "filefmt", + srcs = [ + "gitattributes.go", + "json.go", + "paths.go", + "toml.go", + "yaml.go", + ], + importpath = "github.com/ophidiarium/moltark/internal/filefmt", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/model", + "@com_github_burntsushi_toml//:toml", + "@in_gopkg_yaml_v3//:yaml_v3", + ], +) + +go_test( + name = "filefmt_test", + srcs = [ + "gitattributes_test.go", + "json_test.go", + "paths_test.go", + "toml_test.go", + "yaml_test.go", + ], + embed = [":filefmt"], + deps = [ + "//internal/model", + "@com_github_burntsushi_toml//:toml", + ], +) diff --git a/internal/moltark/gitattributes.go b/internal/filefmt/gitattributes.go similarity index 80% rename from internal/moltark/gitattributes.go rename to internal/filefmt/gitattributes.go index 24b52e4..e45267a 100644 --- a/internal/moltark/gitattributes.go +++ b/internal/filefmt/gitattributes.go @@ -1,4 +1,4 @@ -package moltark +package filefmt import "strings" @@ -7,7 +7,7 @@ const ( gitattributesEnd = "# END Moltark managed" ) -func managedGitattributesBlock() string { +func ManagedGitattributesBlock() string { return strings.Join([]string{ gitattributesBegin, ".moltark/** linguist-generated=true", @@ -15,8 +15,8 @@ func managedGitattributesBlock() string { }, "\n") } -func mutateGitattributes(raw string) string { - block := managedGitattributesBlock() +func MutateGitattributes(raw string) string { + block := ManagedGitattributesBlock() if strings.TrimSpace(raw) == "" { return block + "\n" } @@ -24,14 +24,14 @@ func mutateGitattributes(raw string) string { begin, end, ok := managedBlockBounds(raw) if ok { updated := raw[:begin] + block + raw[end:] - return ensureTrailingNewline(updated) + return EnsureTrailingNewline(updated) } trimmed := strings.TrimRight(raw, "\n") return trimmed + "\n\n" + block + "\n" } -func currentManagedGitattributesBlock(raw string) (string, bool) { +func CurrentManagedGitattributesBlock(raw string) (string, bool) { begin, end, ok := managedBlockBounds(raw) if !ok { return "", false diff --git a/internal/moltark/gitattributes_test.go b/internal/filefmt/gitattributes_test.go similarity index 86% rename from internal/moltark/gitattributes_test.go rename to internal/filefmt/gitattributes_test.go index bfdc39f..94eec61 100644 --- a/internal/moltark/gitattributes_test.go +++ b/internal/filefmt/gitattributes_test.go @@ -1,11 +1,11 @@ -package moltark +package filefmt import "testing" func TestCurrentManagedGitattributesBlockUsesEndAfterMatchedBegin(t *testing.T) { raw := "# END Moltark managed\n*.go text eol=lf\n# BEGIN Moltark managed\nold block\n# END Moltark managed\n" - block, ok := currentManagedGitattributesBlock(raw) + block, ok := CurrentManagedGitattributesBlock(raw) if !ok { t.Fatal("expected managed block to be found") } diff --git a/internal/moltark/jsonfile.go b/internal/filefmt/json.go similarity index 54% rename from internal/moltark/jsonfile.go rename to internal/filefmt/json.go index 1fdd773..688344a 100644 --- a/internal/moltark/jsonfile.go +++ b/internal/filefmt/json.go @@ -1,11 +1,13 @@ -package moltark +package filefmt import ( "bytes" "encoding/json" + + "github.com/ophidiarium/moltark/internal/model" ) -func parseJSONValues(raw []byte) (map[string]any, error) { +func ParseJSONValues(raw []byte) (map[string]any, error) { values := map[string]any{} if len(bytes.TrimSpace(raw)) == 0 { return values, nil @@ -16,10 +18,10 @@ func parseJSONValues(raw []byte) (map[string]any, error) { return values, nil } -func mutateJSONFile(raw string, desiredValues map[string]any, ownedPaths []string) (string, error) { +func MutateJSONFile(raw string, desiredValues map[string]any, ownedPaths []string) (string, error) { values := map[string]any{} if len(bytes.TrimSpace([]byte(raw))) > 0 { - parsed, err := parseJSONValues([]byte(raw)) + parsed, err := ParseJSONValues([]byte(raw)) if err != nil { return "", err } @@ -27,25 +29,25 @@ func mutateJSONFile(raw string, desiredValues map[string]any, ownedPaths []strin } for _, ownedPath := range ownedPaths { - value, err := requireStructuredValue(desiredValues, FileFormatJSON, ownedPath) + value, err := RequireStructuredValue(desiredValues, model.FileFormatJSON, ownedPath) if err != nil { return "", err } - setStructuredValue(values, FileFormatJSON, ownedPath, value) + SetStructuredValue(values, model.FileFormatJSON, ownedPath, value) } - return renderJSONFile(values) + return RenderJSONFile(values) } -func renderJSONFile(values map[string]any) (string, error) { +func RenderJSONFile(values map[string]any) (string, error) { encoded, err := json.MarshalIndent(values, "", " ") if err != nil { return "", err } - return ensureTrailingNewline(string(encoded)), nil + return EnsureTrailingNewline(string(encoded)), nil } -func renderJSONValue(value any) string { +func RenderJSONValue(value any) string { encoded, err := json.Marshal(value) if err != nil { return `""` diff --git a/internal/moltark/jsonfile_test.go b/internal/filefmt/json_test.go similarity index 85% rename from internal/moltark/jsonfile_test.go rename to internal/filefmt/json_test.go index 3662e67..cfb1d85 100644 --- a/internal/moltark/jsonfile_test.go +++ b/internal/filefmt/json_test.go @@ -1,4 +1,4 @@ -package moltark +package filefmt import ( "strings" @@ -22,9 +22,9 @@ func TestMutateJSONFileMergesOwnedPathsIntoExistingDocument(t *testing.T) { }, } - got, err := mutateJSONFile(raw, desiredValues, []string{"/vscode/extensions"}) + got, err := MutateJSONFile(raw, desiredValues, []string{"/vscode/extensions"}) if err != nil { - t.Fatalf("mutateJSONFile: %v", err) + t.Fatalf("MutateJSONFile: %v", err) } if !strings.Contains(got, `"charliermarsh.ruff"`) { @@ -36,7 +36,7 @@ func TestMutateJSONFileMergesOwnedPathsIntoExistingDocument(t *testing.T) { } func TestMutateJSONFileRejectsMissingOwnedPaths(t *testing.T) { - _, err := mutateJSONFile("{}", map[string]any{ + _, err := MutateJSONFile("{}", map[string]any{ "vscode": map[string]any{}, }, []string{"/vscode/extensions"}) if err == nil { diff --git a/internal/moltark/structured_paths.go b/internal/filefmt/paths.go similarity index 88% rename from internal/moltark/structured_paths.go rename to internal/filefmt/paths.go index 54831ff..946701d 100644 --- a/internal/moltark/structured_paths.go +++ b/internal/filefmt/paths.go @@ -1,12 +1,14 @@ -package moltark +package filefmt import ( "fmt" "sort" "strings" + + "github.com/ophidiarium/moltark/internal/model" ) -func lookupStructuredValue(values map[string]any, format string, path string) (any, bool) { +func LookupStructuredValue(values map[string]any, format string, path string) (any, bool) { current := any(values) for _, part := range structuredPathParts(format, path) { nested, ok := current.(map[string]any) @@ -21,15 +23,15 @@ func lookupStructuredValue(values map[string]any, format string, path string) (a return current, true } -func requireStructuredValue(values map[string]any, format string, path string) (any, error) { - value, ok := lookupStructuredValue(values, format, path) +func RequireStructuredValue(values map[string]any, format string, path string) (any, error) { + value, ok := LookupStructuredValue(values, format, path) if !ok { return nil, fmt.Errorf("missing desired value for owned path %q", path) } return value, nil } -func setStructuredValue(values map[string]any, format string, path string, value any) { +func SetStructuredValue(values map[string]any, format string, path string, value any) { parts := structuredPathParts(format, path) if len(parts) == 0 { return @@ -49,7 +51,7 @@ func setStructuredValue(values map[string]any, format string, path string, value func structuredPathParts(format string, path string) []string { switch format { - case FileFormatJSON, FileFormatYAML: + case model.FileFormatJSON, model.FileFormatYAML: return jsonPointerParts(path) default: return strings.Split(path, ".") @@ -88,21 +90,21 @@ func encodeJSONPointer(parts ...string) string { return "/" + strings.Join(encoded, "/") } -func inferOwnedPaths(format string, value any) ([]string, error) { +func InferOwnedPaths(format string, value any) ([]string, error) { switch format { - case FileFormatJSON: + case model.FileFormatJSON: root, ok := value.(map[string]any) if !ok { return nil, fmt.Errorf("json file values must be an object") } return inferJSONOwnedPaths(root, nil), nil - case FileFormatYAML: + case model.FileFormatYAML: root, ok := value.(map[string]any) if !ok { return nil, fmt.Errorf("yaml file values must be an object") } return inferJSONOwnedPaths(root, nil), nil - case FileFormatTOML: + case model.FileFormatTOML: root, ok := value.(map[string]any) if !ok { return nil, fmt.Errorf("toml file values must be an object") diff --git a/internal/moltark/structured_paths_test.go b/internal/filefmt/paths_test.go similarity index 77% rename from internal/moltark/structured_paths_test.go rename to internal/filefmt/paths_test.go index a91e45e..2befe8f 100644 --- a/internal/moltark/structured_paths_test.go +++ b/internal/filefmt/paths_test.go @@ -1,13 +1,15 @@ -package moltark +package filefmt import ( "reflect" "strings" "testing" + + "github.com/ophidiarium/moltark/internal/model" ) func TestInferOwnedPathsForTOMLUsesDottedPaths(t *testing.T) { - paths, err := inferOwnedPaths(FileFormatTOML, map[string]any{ + paths, err := InferOwnedPaths(model.FileFormatTOML, map[string]any{ "default": map[string]any{ "extend-words": map[string]any{ "teh": "teh", @@ -18,7 +20,7 @@ func TestInferOwnedPathsForTOMLUsesDottedPaths(t *testing.T) { }, }) if err != nil { - t.Fatalf("inferOwnedPaths: %v", err) + t.Fatalf("InferOwnedPaths: %v", err) } want := []string{ @@ -31,7 +33,7 @@ func TestInferOwnedPathsForTOMLUsesDottedPaths(t *testing.T) { } func TestInferOwnedPathsForYAMLUsesJSONPointers(t *testing.T) { - paths, err := inferOwnedPaths(FileFormatYAML, map[string]any{ + paths, err := InferOwnedPaths(model.FileFormatYAML, map[string]any{ "docs": map[string]any{ "changed-files": map[string]any{ "any-glob-to-any-file": []any{"docs/**"}, @@ -39,7 +41,7 @@ func TestInferOwnedPathsForYAMLUsesJSONPointers(t *testing.T) { }, }) if err != nil { - t.Fatalf("inferOwnedPaths: %v", err) + t.Fatalf("InferOwnedPaths: %v", err) } want := []string{"/docs/changed-files/any-glob-to-any-file"} @@ -49,7 +51,7 @@ func TestInferOwnedPathsForYAMLUsesJSONPointers(t *testing.T) { } func TestInferOwnedPathsForTOMLRejectsLiteralDotKeys(t *testing.T) { - _, err := inferOwnedPaths(FileFormatTOML, map[string]any{ + _, err := InferOwnedPaths(model.FileFormatTOML, map[string]any{ "tool.ruff": map[string]any{ "line-length": 100, }, diff --git a/internal/moltark/tomlfile.go b/internal/filefmt/toml.go similarity index 91% rename from internal/moltark/tomlfile.go rename to internal/filefmt/toml.go index 9df2d9f..b71acb4 100644 --- a/internal/moltark/tomlfile.go +++ b/internal/filefmt/toml.go @@ -1,4 +1,4 @@ -package moltark +package filefmt import ( "fmt" @@ -9,28 +9,29 @@ import ( "strings" "github.com/BurntSushi/toml" + "github.com/ophidiarium/moltark/internal/model" ) -func decodeToml(raw []byte, v any) error { +func DecodeToml(raw []byte, v any) error { return toml.Unmarshal(raw, v) } -func mutateTOMLFile(raw string, desiredValues map[string]any, ownedPaths []string) (string, error) { +func MutateTOMLFile(raw string, desiredValues map[string]any, ownedPaths []string) (string, error) { if len(ownedPaths) == 0 { return raw, nil } if strings.TrimSpace(raw) == "" { - return renderTOMLFile(desiredValues, ownedPaths) + return RenderTOMLFile(desiredValues, ownedPaths) } updates := make([]tomlUpdate, 0, len(ownedPaths)) for _, ownedPath := range ownedPaths { table, key := splitOwnedPath(ownedPath) - value, err := requireStructuredValue(desiredValues, FileFormatTOML, ownedPath) + value, err := RequireStructuredValue(desiredValues, model.FileFormatTOML, ownedPath) if err != nil { return "", err } - rendered, err := renderTomlValue(value) + rendered, err := RenderTomlValue(value) if err != nil { return "", fmt.Errorf("render %s: %w", ownedPath, err) } @@ -50,10 +51,10 @@ func mutateTOMLFile(raw string, desiredValues map[string]any, ownedPaths []strin } } - return ensureTrailingNewline(out), nil + return EnsureTrailingNewline(out), nil } -func renderTOMLFile(desiredValues map[string]any, ownedPaths []string) (string, error) { +func RenderTOMLFile(desiredValues map[string]any, ownedPaths []string) (string, error) { if len(ownedPaths) == 0 { return "", nil } @@ -77,11 +78,11 @@ func renderTOMLFile(desiredValues map[string]any, ownedPaths []string) (string, } lines := []string{} for _, key := range rootKeys { - value, err := requireStructuredValue(desiredValues, FileFormatTOML, key) + value, err := RequireStructuredValue(desiredValues, model.FileFormatTOML, key) if err != nil { return "", err } - rendered, err := renderTomlValue(value) + rendered, err := RenderTomlValue(value) if err != nil { return "", fmt.Errorf("render %s: %w", key, err) } @@ -95,11 +96,11 @@ func renderTOMLFile(desiredValues map[string]any, ownedPaths []string) (string, lines = append(lines, "["+table+"]") for _, key := range keys { ownedPath := table + "." + key - value, err := requireStructuredValue(desiredValues, FileFormatTOML, ownedPath) + value, err := RequireStructuredValue(desiredValues, model.FileFormatTOML, ownedPath) if err != nil { return "", err } - rendered, err := renderTomlValue(value) + rendered, err := RenderTomlValue(value) if err != nil { return "", fmt.Errorf("render %s: %w", ownedPath, err) } @@ -124,7 +125,7 @@ func splitOwnedPath(path string) (string, string) { return path[:index], path[index+1:] } -func renderTomlValue(value any) (string, error) { +func RenderTomlValue(value any) (string, error) { switch typed := value.(type) { case nil: return `""`, nil @@ -151,7 +152,7 @@ func renderTomlValue(value any) (string, error) { case []any: items := make([]string, 0, len(typed)) for _, item := range typed { - rendered, err := renderTomlValue(item) + rendered, err := RenderTomlValue(item) if err != nil { return "", err } @@ -173,7 +174,7 @@ func renderTomlValue(value any) (string, error) { case reflect.Slice, reflect.Array: items := make([]string, 0, valueRef.Len()) for i := 0; i < valueRef.Len(); i++ { - rendered, err := renderTomlValue(valueRef.Index(i).Interface()) + rendered, err := RenderTomlValue(valueRef.Index(i).Interface()) if err != nil { return "", err } @@ -203,7 +204,7 @@ func renderTomlInlineTable(values map[string]any) (string, error) { items := make([]string, 0, len(keys)) for _, key := range keys { - rendered, err := renderTomlValue(values[key]) + rendered, err := RenderTomlValue(values[key]) if err != nil { return "", err } @@ -451,7 +452,7 @@ func assignmentIndex(line string) int { func isCompleteTomlAssignment(snippet string) bool { var values map[string]any - return decodeToml([]byte(snippet), &values) == nil + return DecodeToml([]byte(snippet), &values) == nil } func insertLines(lines []string, at int, inserts []string) []string { @@ -464,7 +465,7 @@ func insertLines(lines []string, at int, inserts []string) []string { return result } -func ensureTrailingNewline(raw string) string { +func EnsureTrailingNewline(raw string) string { if raw == "" || strings.HasSuffix(raw, "\n") { return raw } diff --git a/internal/moltark/tomlfile_test.go b/internal/filefmt/toml_test.go similarity index 86% rename from internal/moltark/tomlfile_test.go rename to internal/filefmt/toml_test.go index 9e166ee..72ec14f 100644 --- a/internal/moltark/tomlfile_test.go +++ b/internal/filefmt/toml_test.go @@ -1,4 +1,4 @@ -package moltark +package filefmt import ( "strings" @@ -23,9 +23,9 @@ version = "0.1.0" }, } - got, err := mutateTOMLFile(raw, desiredValues, []string{"project.name"}) + got, err := MutateTOMLFile(raw, desiredValues, []string{"project.name"}) if err != nil { - t.Fatalf("mutateTOMLFile: %v", err) + t.Fatalf("MutateTOMLFile: %v", err) } if !strings.Contains(got, "name = \"renamed\"\n") { @@ -37,9 +37,9 @@ version = "0.1.0" } func TestMutateTOMLFileSupportsRootLevelKeys(t *testing.T) { - got, err := mutateTOMLFile("", map[string]any{"schema-version": 1}, []string{"schema-version"}) + got, err := MutateTOMLFile("", map[string]any{"schema-version": 1}, []string{"schema-version"}) if err != nil { - t.Fatalf("mutateTOMLFile: %v", err) + t.Fatalf("MutateTOMLFile: %v", err) } if got != "schema-version = 1\n" { @@ -48,7 +48,7 @@ func TestMutateTOMLFileSupportsRootLevelKeys(t *testing.T) { } func TestMutateTOMLFileRejectsMissingOwnedPaths(t *testing.T) { - _, err := mutateTOMLFile("", map[string]any{ + _, err := MutateTOMLFile("", map[string]any{ "tool": map[string]any{}, }, []string{"tool.moltark.schema-version"}) if err == nil { @@ -74,9 +74,9 @@ version = "0.1.0" }, } - got, err := mutateTOMLFile(raw, desiredValues, []string{"project.name"}) + got, err := MutateTOMLFile(raw, desiredValues, []string{"project.name"}) if err != nil { - t.Fatalf("mutateTOMLFile: %v", err) + t.Fatalf("MutateTOMLFile: %v", err) } if !strings.Contains(got, "name = \"updated\"\n") { @@ -105,9 +105,9 @@ requires = ["hatchling"] }, } - got, err := mutateTOMLFile(raw, desiredValues, []string{"build-system.requires"}) + got, err := MutateTOMLFile(raw, desiredValues, []string{"build-system.requires"}) if err != nil { - t.Fatalf("mutateTOMLFile: %v", err) + t.Fatalf("MutateTOMLFile: %v", err) } if !strings.Contains(got, `requires = ["flit_core"]`) { @@ -132,9 +132,9 @@ name = "real" }, } - got, err := mutateTOMLFile(raw, desiredValues, []string{"project.name"}) + got, err := MutateTOMLFile(raw, desiredValues, []string{"project.name"}) if err != nil { - t.Fatalf("mutateTOMLFile: %v", err) + t.Fatalf("MutateTOMLFile: %v", err) } if !strings.Contains(got, "name = \"updated\"\n") { @@ -160,9 +160,9 @@ name = "demo" }, } - got, err := mutateTOMLFile(raw, desiredValues, []string{"project.name"}) + got, err := MutateTOMLFile(raw, desiredValues, []string{"project.name"}) if err != nil { - t.Fatalf("mutateTOMLFile: %v", err) + t.Fatalf("MutateTOMLFile: %v", err) } // The output must be valid TOML with the correct project.name value. @@ -200,9 +200,9 @@ url = "https://example.com/simple" }, } - got, err := mutateTOMLFile(raw, desiredValues, []string{"tool.uv.managed"}) + got, err := MutateTOMLFile(raw, desiredValues, []string{"tool.uv.managed"}) if err != nil { - t.Fatalf("mutateTOMLFile: %v", err) + t.Fatalf("MutateTOMLFile: %v", err) } // New key must land inside [tool.uv], before [[tool.uv.index]]. @@ -225,12 +225,12 @@ url = "https://example.com/simple" } func TestRenderTomlValueRendersValidInlineTables(t *testing.T) { - rendered, err := renderTomlValue(map[string]any{ + rendered, err := RenderTomlValue(map[string]any{ "enabled": true, "tool": "uv", }) if err != nil { - t.Fatalf("renderTomlValue: %v", err) + t.Fatalf("RenderTomlValue: %v", err) } values := map[string]any{} @@ -241,7 +241,7 @@ func TestRenderTomlValueRendersValidInlineTables(t *testing.T) { func TestRenderTomlValueRejectsUnsupportedTypes(t *testing.T) { type custom struct{ X int } - _, err := renderTomlValue(custom{X: 1}) + _, err := RenderTomlValue(custom{X: 1}) if err == nil { t.Fatal("expected error for unsupported type") } diff --git a/internal/moltark/yamlfile.go b/internal/filefmt/yaml.go similarity index 56% rename from internal/moltark/yamlfile.go rename to internal/filefmt/yaml.go index baef153..d0fbd38 100644 --- a/internal/moltark/yamlfile.go +++ b/internal/filefmt/yaml.go @@ -1,12 +1,13 @@ -package moltark +package filefmt import ( "bytes" + "github.com/ophidiarium/moltark/internal/model" "gopkg.in/yaml.v3" ) -func parseYAMLValues(raw []byte) (map[string]any, error) { +func ParseYAMLValues(raw []byte) (map[string]any, error) { values := map[string]any{} if len(bytes.TrimSpace(raw)) == 0 { return values, nil @@ -17,10 +18,10 @@ func parseYAMLValues(raw []byte) (map[string]any, error) { return values, nil } -func mutateYAMLFile(raw string, desiredValues map[string]any, ownedPaths []string) (string, error) { +func MutateYAMLFile(raw string, desiredValues map[string]any, ownedPaths []string) (string, error) { values := map[string]any{} if len(bytes.TrimSpace([]byte(raw))) > 0 { - parsed, err := parseYAMLValues([]byte(raw)) + parsed, err := ParseYAMLValues([]byte(raw)) if err != nil { return "", err } @@ -28,17 +29,17 @@ func mutateYAMLFile(raw string, desiredValues map[string]any, ownedPaths []strin } for _, ownedPath := range ownedPaths { - value, err := requireStructuredValue(desiredValues, FileFormatYAML, ownedPath) + value, err := RequireStructuredValue(desiredValues, model.FileFormatYAML, ownedPath) if err != nil { return "", err } - setStructuredValue(values, FileFormatYAML, ownedPath, value) + SetStructuredValue(values, model.FileFormatYAML, ownedPath, value) } - return renderYAMLFile(values) + return RenderYAMLFile(values) } -func renderYAMLFile(values map[string]any) (string, error) { +func RenderYAMLFile(values map[string]any) (string, error) { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) @@ -48,5 +49,5 @@ func renderYAMLFile(values map[string]any) (string, error) { if err := enc.Close(); err != nil { return "", err } - return ensureTrailingNewline(buf.String()), nil + return EnsureTrailingNewline(buf.String()), nil } diff --git a/internal/moltark/yamlfile_test.go b/internal/filefmt/yaml_test.go similarity index 86% rename from internal/moltark/yamlfile_test.go rename to internal/filefmt/yaml_test.go index 2d57bb8..51f569d 100644 --- a/internal/moltark/yamlfile_test.go +++ b/internal/filefmt/yaml_test.go @@ -1,4 +1,4 @@ -package moltark +package filefmt import ( "strings" @@ -21,9 +21,9 @@ keep: true }, } - got, err := mutateYAMLFile(raw, desiredValues, []string{"/docs/changed-files/any-glob-to-any-file"}) + got, err := MutateYAMLFile(raw, desiredValues, []string{"/docs/changed-files/any-glob-to-any-file"}) if err != nil { - t.Fatalf("mutateYAMLFile: %v", err) + t.Fatalf("MutateYAMLFile: %v", err) } if !strings.Contains(got, "- README.md\n") { @@ -35,7 +35,7 @@ keep: true } func TestMutateYAMLFileRejectsMissingOwnedPaths(t *testing.T) { - _, err := mutateYAMLFile("{}", map[string]any{ + _, err := MutateYAMLFile("{}", map[string]any{ "docs": map[string]any{}, }, []string{"/docs/changed-files/any-glob-to-any-file"}) if err == nil { diff --git a/internal/model/BUILD.bazel b/internal/model/BUILD.bazel new file mode 100644 index 0000000..e091764 --- /dev/null +++ b/internal/model/BUILD.bazel @@ -0,0 +1,11 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "model", + srcs = [ + "constants.go", + "types.go", + ], + importpath = "github.com/ophidiarium/moltark/internal/model", + visibility = ["//:__subpackages__"], +) diff --git a/internal/moltark/constants.go b/internal/model/constants.go similarity index 82% rename from internal/moltark/constants.go rename to internal/model/constants.go index 8b913d0..6ee7a19 100644 --- a/internal/moltark/constants.go +++ b/internal/model/constants.go @@ -1,6 +1,4 @@ -package moltark - -import "path/filepath" +package model const ( // ProjectSpecFileName is the Starlark file at the repository root that @@ -60,19 +58,3 @@ const ( ArtifactKindPyPI = "pypi" ) - -var baseOwnedPyprojectPaths = []string{ - "project.name", - "project.version", - "project.requires-python", - "build-system.requires", - "build-system.build-backend", - "tool.moltark.schema-version", - "tool.moltark.template-version", -} - -const uvWorkspaceMembersPath = "tool.uv.workspace.members" - -func statePath(root string) string { - return filepath.Join(root, StateDirName, StateFileName) -} diff --git a/internal/moltark/types.go b/internal/model/types.go similarity index 88% rename from internal/moltark/types.go rename to internal/model/types.go index 74fa6ec..f7303eb 100644 --- a/internal/moltark/types.go +++ b/internal/model/types.go @@ -1,4 +1,6 @@ -package moltark +package model + +import "path/filepath" type DesiredModel struct { Projects []ProjectSpec `json:"projects"` @@ -285,7 +287,7 @@ type PlanSummary struct { Conflict int `json:"conflict"` } -type fileDocument struct { +type FileDocument struct { Raw string Exists bool Values map[string]any @@ -329,20 +331,6 @@ type PlanningPhase struct { Summary PlanSummary `json:"summary"` } -type Pipeline struct { - Root string `json:"-"` - Evaluate EvaluationPhase `json:"evaluate"` - Resolve ResolutionPhase `json:"resolve"` - Inspect InspectionPhase `json:"inspect"` - Persist PersistPhase `json:"persist"` - Plan PlanningPhase `json:"plan"` - - fileDocs map[string]fileDocument - stateRaw string - nextStateRaw string - gitattributesRaw string -} - type Plan struct { Desired DesiredModel `json:"desired"` Resolved ResolvedModel `json:"resolved"` @@ -370,7 +358,7 @@ type DoctorReport struct { Messages []string `json:"messages"` } -func (m DesiredModel) projectByID(id string) *ProjectSpec { +func (m DesiredModel) ProjectByID(id string) *ProjectSpec { for i := range m.Projects { if m.Projects[i].ID == id { return &m.Projects[i] @@ -379,14 +367,14 @@ func (m DesiredModel) projectByID(id string) *ProjectSpec { return nil } -func (m DesiredModel) projectPyprojectPath(project ProjectSpec) string { +func (m DesiredModel) ProjectPyprojectPath(project ProjectSpec) string { if project.EffectivePath == "." { return PyprojectFileName } - return joinProjectPath(project.EffectivePath, PyprojectFileName) + return JoinProjectPath(project.EffectivePath, PyprojectFileName) } -func (m ModelSummary) componentVersion(id string) (string, bool) { +func (m ModelSummary) ComponentVersion(id string) (string, bool) { for _, component := range m.Components { if component.ID == id { return component.Version, true @@ -394,3 +382,78 @@ func (m ModelSummary) componentVersion(id string) (string, bool) { } return "", false } + +func (s *State) ManagedFile(path string) *ManagedFileState { + if s == nil { + return nil + } + + for i := range s.ManagedFiles { + if s.ManagedFiles[i].Path == path { + return &s.ManagedFiles[i] + } + } + + return nil +} + +func JoinProjectPath(base string, child string) string { + if base == "." { + return child + } + if child == "." { + return base + } + return filepath.ToSlash(filepath.Join(base, child)) +} + +func CloneNestedMap(values map[string]any) map[string]any { + cloned := map[string]any{} + for key, value := range values { + cloned[key] = CloneStructuredValue(value) + } + return cloned +} + +func CloneSlice(values []any) []any { + cloned := make([]any, 0, len(values)) + for _, value := range values { + cloned = append(cloned, CloneStructuredValue(value)) + } + return cloned +} + +func CloneStructuredValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return CloneNestedMap(typed) + case []string: + return append([]string(nil), typed...) + case []any: + return CloneSlice(typed) + default: + return value + } +} + +func CloneStringMap(values map[string]string) map[string]string { + if len(values) == 0 { + return nil + } + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func CloneStringSliceMap(values map[string][]string) map[string][]string { + if len(values) == 0 { + return nil + } + cloned := make(map[string][]string, len(values)) + for key, value := range values { + cloned[key] = append([]string(nil), value...) + } + return cloned +} diff --git a/internal/module/BUILD.bazel b/internal/module/BUILD.bazel new file mode 100644 index 0000000..7be5edf --- /dev/null +++ b/internal/module/BUILD.bazel @@ -0,0 +1,38 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "module", + srcs = [ + "config.go", + "convert.go", + "core.go", + "factref.go", + "pyproject.go", + "python.go", + "registry.go", + "uv.go", + ], + importpath = "github.com/ophidiarium/moltark/internal/module", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/filefmt", + "//internal/model", + "@net_starlark_go//starlark", + "@net_starlark_go//starlarkstruct", + "@net_starlark_go//syntax", + ], +) + +go_test( + name = "module_test", + srcs = [ + "config_test.go", + "core_test.go", + "facts_test.go", + ], + embed = [":module"], + deps = [ + "//internal/model", + "@net_starlark_go//starlark", + ], +) diff --git a/internal/moltark/config.go b/internal/module/config.go similarity index 68% rename from internal/moltark/config.go rename to internal/module/config.go index 7685985..79bebe0 100644 --- a/internal/moltark/config.go +++ b/internal/module/config.go @@ -1,4 +1,4 @@ -package moltark +package module import ( "fmt" @@ -7,14 +7,16 @@ import ( "path/filepath" "strings" + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" "go.starlark.net/starlark" ) -func LoadDesiredModel(root string) (DesiredModel, error) { - path := filepath.Join(root, ProjectSpecFileName) +func LoadDesiredModel(root string) (model.DesiredModel, error) { + path := filepath.Join(root, model.ProjectSpecFileName) src, err := os.ReadFile(path) if err != nil { - return DesiredModel{}, fmt.Errorf("read %s: %w", ProjectSpecFileName, err) + return model.DesiredModel{}, fmt.Errorf("read %s: %w", model.ProjectSpecFileName, err) } builder := newDesiredModelBuilder() @@ -22,27 +24,27 @@ func LoadDesiredModel(root string) (DesiredModel, error) { "use": starlark.NewBuiltin("use", builder.useModule), } - thread := &starlark.Thread{Name: ProjectSpecFileName} - if _, err := starlark.ExecFile(thread, ProjectSpecFileName, src, globals); err != nil { - return DesiredModel{}, fmt.Errorf("evaluate %s: %w", ProjectSpecFileName, err) + thread := &starlark.Thread{Name: model.ProjectSpecFileName} + if _, err := starlark.ExecFile(thread, model.ProjectSpecFileName, src, globals); err != nil { + return model.DesiredModel{}, fmt.Errorf("evaluate %s: %w", model.ProjectSpecFileName, err) } return builder.build() } func InitRepository(root string) (string, error) { - path := filepath.Join(root, ProjectSpecFileName) + path := filepath.Join(root, model.ProjectSpecFileName) if _, err := os.Stat(path); err == nil { - return fmt.Sprintf("%s already exists. No changes made.", ProjectSpecFileName), nil + return fmt.Sprintf("%s already exists. No changes made.", model.ProjectSpecFileName), nil } else if !os.IsNotExist(err) { - return "", fmt.Errorf("stat %s: %w", ProjectSpecFileName, err) + return "", fmt.Errorf("stat %s: %w", model.ProjectSpecFileName, err) } name := filepath.Base(root) - version := DefaultProjectVersion - requiresPython := DefaultRequiresPython + version := model.DefaultProjectVersion + requiresPython := model.DefaultRequiresPython - if raw, err := os.ReadFile(filepath.Join(root, PyprojectFileName)); err == nil { + if raw, err := os.ReadFile(filepath.Join(root, model.PyprojectFileName)); err == nil { var info struct { Project struct { Name string `toml:"name"` @@ -50,7 +52,7 @@ func InitRepository(root string) (string, error) { RequiresPython string `toml:"requires-python"` } `toml:"project"` } - if err := decodeToml(raw, &info); err != nil { + if err := filefmt.DecodeToml(raw, &info); err != nil { return "", fmt.Errorf("read existing pyproject.toml: %w", err) } if info.Project.Name != "" { @@ -66,22 +68,22 @@ func InitRepository(root string) (string, error) { content := fmt.Sprintf( "python = use(%q)\n\nroot = python.python_project(\n name = %q,\n path = \".\",\n version = %q,\n requires_python = %q,\n)\n", - ModuleSourcePython, + model.ModuleSourcePython, name, version, requiresPython, ) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - return "", fmt.Errorf("write %s: %w", ProjectSpecFileName, err) + return "", fmt.Errorf("write %s: %w", model.ProjectSpecFileName, err) } - return fmt.Sprintf("Created %s. Run `moltark plan` to inspect the initial reconciliation.", ProjectSpecFileName), nil + return fmt.Sprintf("Created %s. Run `moltark plan` to inspect the initial reconciliation.", model.ProjectSpecFileName), nil } type desiredModelBuilder struct { - projects []*ProjectSpec - projectByID map[string]*ProjectSpec + projects []*model.ProjectSpec + projectByID map[string]*model.ProjectSpec modules map[string]localModule moduleFactories map[string]localModuleFactory moduleBuildOrder []string @@ -91,7 +93,7 @@ type desiredModelBuilder struct { func newDesiredModelBuilder() *desiredModelBuilder { builder := &desiredModelBuilder{ - projectByID: map[string]*ProjectSpec{}, + projectByID: map[string]*model.ProjectSpec{}, modules: map[string]localModule{}, } builder.registerLocalModules() @@ -119,37 +121,37 @@ func (b *desiredModelBuilder) useModule(_ *starlark.Thread, _ *starlark.Builtin, return module.Namespace(), nil } -func (b *desiredModelBuilder) build() (DesiredModel, error) { +func (b *desiredModelBuilder) build() (model.DesiredModel, error) { if len(b.projects) == 0 { - return DesiredModel{}, fmt.Errorf("%s did not declare any projects", ProjectSpecFileName) + return model.DesiredModel{}, fmt.Errorf("%s did not declare any projects", model.ProjectSpecFileName) } - model := DesiredModel{ - Projects: make([]ProjectSpec, len(b.projects)), + desired := model.DesiredModel{ + Projects: make([]model.ProjectSpec, len(b.projects)), } for i, project := range b.projects { - model.Projects[i] = *project + desired.Projects[i] = *project } - if err := normalizeProjects(&model); err != nil { - return DesiredModel{}, err + if err := normalizeProjects(&desired); err != nil { + return model.DesiredModel{}, err } - components := []ComponentSpec{} + components := []model.ComponentSpec{} for _, source := range b.moduleBuildOrder { module, ok := b.modules[source] if !ok { continue } - moduleComponents, err := module.BuildComponents(model) + moduleComponents, err := module.BuildComponents(desired) if err != nil { - return DesiredModel{}, err + return model.DesiredModel{}, err } components = append(components, moduleComponents...) } - model.Components = components + desired.Components = components - return model, nil + return desired, nil } func (b *desiredModelBuilder) nextProjectName() string { @@ -162,24 +164,24 @@ func (b *desiredModelBuilder) nextComponentName() string { return fmt.Sprintf("component_%d", b.nextComponentID) } -func normalizeProjects(model *DesiredModel) error { - projectByID := make(map[string]*ProjectSpec, len(model.Projects)) - for i := range model.Projects { - projectByID[model.Projects[i].ID] = &model.Projects[i] +func normalizeProjects(desired *model.DesiredModel) error { + projectByID := make(map[string]*model.ProjectSpec, len(desired.Projects)) + for i := range desired.Projects { + projectByID[desired.Projects[i].ID] = &desired.Projects[i] } resolving := map[string]bool{} resolved := map[string]bool{} - for i := range model.Projects { - effectivePath, err := resolveProjectPath(&model.Projects[i], projectByID, resolving, resolved) + for i := range desired.Projects { + effectivePath, err := resolveProjectPath(&desired.Projects[i], projectByID, resolving, resolved) if err != nil { return err } - model.Projects[i].EffectivePath = effectivePath + desired.Projects[i].EffectivePath = effectivePath } paths := map[string]string{} - for _, project := range model.Projects { + for _, project := range desired.Projects { if existing, ok := paths[project.EffectivePath]; ok { return fmt.Errorf("project %q conflicts with project %q at path %q", project.ID, existing, project.EffectivePath) } @@ -189,7 +191,7 @@ func normalizeProjects(model *DesiredModel) error { return nil } -func resolveProjectPath(project *ProjectSpec, projectByID map[string]*ProjectSpec, resolving map[string]bool, resolved map[string]bool) (string, error) { +func resolveProjectPath(project *model.ProjectSpec, projectByID map[string]*model.ProjectSpec, resolving map[string]bool, resolved map[string]bool) (string, error) { if resolved[project.ID] { return project.EffectivePath, nil } @@ -221,7 +223,7 @@ func resolveProjectPath(project *ProjectSpec, projectByID map[string]*ProjectSpe return "", err } - project.EffectivePath = joinProjectPath(parentPath, normalizedPath) + project.EffectivePath = model.JoinProjectPath(parentPath, normalizedPath) resolved[project.ID] = true return project.EffectivePath, nil } @@ -255,16 +257,6 @@ func (b *desiredModelBuilder) requireProjectRef(value starlark.Value, label stri return ref.id, nil } -func joinProjectPath(base string, child string) string { - if base == "." { - return child - } - if child == "." { - return base - } - return filepath.ToSlash(filepath.Join(base, child)) -} - func relativeWorkspaceMemberPath(rootPath string, memberPath string) (string, error) { relativePath, err := filepath.Rel(rootPath, memberPath) if err != nil { diff --git a/internal/moltark/config_test.go b/internal/module/config_test.go similarity index 69% rename from internal/moltark/config_test.go rename to internal/module/config_test.go index c306a38..b96c014 100644 --- a/internal/moltark/config_test.go +++ b/internal/module/config_test.go @@ -1,10 +1,12 @@ -package moltark +package module import ( "os" "path/filepath" "strings" "testing" + + "github.com/ophidiarium/moltark/internal/model" ) func TestLoadDesiredModelRejectsModuleVersions(t *testing.T) { @@ -17,8 +19,8 @@ python.python_project( requires_python = ">=3.12", ) ` - if err := os.WriteFile(filepath.Join(root, ProjectSpecFileName), []byte(content), 0o644); err != nil { - t.Fatalf("write %s: %v", ProjectSpecFileName, err) + if err := os.WriteFile(filepath.Join(root, model.ProjectSpecFileName), []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", model.ProjectSpecFileName, err) } _, err := LoadDesiredModel(root) diff --git a/internal/moltark/starlark_convert.go b/internal/module/convert.go similarity index 94% rename from internal/moltark/starlark_convert.go rename to internal/module/convert.go index de96949..eaa30cc 100644 --- a/internal/moltark/starlark_convert.go +++ b/internal/module/convert.go @@ -1,8 +1,9 @@ -package moltark +package module import ( "fmt" + "github.com/ophidiarium/moltark/internal/model" "go.starlark.net/starlark" ) @@ -59,7 +60,7 @@ func starlarkValueToGo(value starlark.Value) (any, error) { } return values, nil case *factValueRefValue: - return FactValueRef{ + return model.FactValueRef{ TargetProjectID: typed.targetProjectID, Name: typed.name, Path: typed.path, diff --git a/internal/moltark/module_core.go b/internal/module/core.go similarity index 86% rename from internal/moltark/module_core.go rename to internal/module/core.go index 766ce19..59877ad 100644 --- a/internal/moltark/module_core.go +++ b/internal/module/core.go @@ -1,9 +1,11 @@ -package moltark +package module import ( "fmt" "strings" + "github.com/ophidiarium/moltark/internal/filefmt" + "github.com/ophidiarium/moltark/internal/model" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" ) @@ -77,7 +79,7 @@ func newCoreModuleRuntime(builder *desiredModelBuilder) localModule { } func (m *coreModuleRuntime) Namespace() starlark.Value { - return starlarkstruct.FromStringDict(starlark.String(ModuleSourceCore), starlark.StringDict{ + return starlarkstruct.FromStringDict(starlark.String(model.ModuleSourceCore), starlark.StringDict{ "project": starlark.NewBuiltin("project", m.project), "fact": starlark.NewBuiltin("fact", m.fact), "fact_value": starlark.NewBuiltin("fact_value", m.factValue), @@ -93,21 +95,21 @@ func (m *coreModuleRuntime) Namespace() starlark.Value { }) } -func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, error) { - components := make([]ComponentSpec, 0, len(m.factDecls)+len(m.structuredFileDecls)+len(m.synthesisHookDecls)+len(m.bootstrapRequirementDecls)+len(m.pythonDependencyDecls)+len(m.taskDecls)+len(m.taskSurfaceDecls)+len(m.triggerBindingDecls)) +func (m *coreModuleRuntime) BuildComponents(_ model.DesiredModel) ([]model.ComponentSpec, error) { + components := make([]model.ComponentSpec, 0, len(m.factDecls)+len(m.structuredFileDecls)+len(m.synthesisHookDecls)+len(m.bootstrapRequirementDecls)+len(m.pythonDependencyDecls)+len(m.taskDecls)+len(m.taskSurfaceDecls)+len(m.triggerBindingDecls)) for _, decl := range m.factDecls { - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "fact", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - Facts: []FactProviderSpec{ + Facts: []model.FactProviderSpec{ { Name: decl.name, ScopeProjectID: decl.targetProjectID, - Values: cloneNestedMap(decl.values), + Values: model.CloneNestedMap(decl.values), }, }, }) @@ -119,36 +121,36 @@ func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, er return nil, fmt.Errorf("%s_file target %q is not declared", decl.format, decl.targetProjectID) } - ownedPaths, err := inferOwnedPaths(decl.format, decl.values) + ownedPaths, err := filefmt.InferOwnedPaths(decl.format, decl.values) if err != nil { return nil, fmt.Errorf("%s_file %q: %w", decl.format, decl.path, err) } - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: decl.format + "_file", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - Files: []StructuredFileSpec{ + Files: []model.StructuredFileSpec{ { - Path: joinProjectPath(project.EffectivePath, decl.path), + Path: model.JoinProjectPath(project.EffectivePath, decl.path), Format: decl.format, OwnedPaths: ownedPaths, - DesiredValues: cloneNestedMap(decl.values), + DesiredValues: model.CloneNestedMap(decl.values), }, }, }) } for _, decl := range m.synthesisHookDecls { - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "synthesis_hook", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - SynthesisHooks: []SynthesisHookSpec{ + SynthesisHooks: []model.SynthesisHookSpec{ { Phase: decl.phase, TargetProjectID: decl.targetProjectID, @@ -159,13 +161,13 @@ func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, er } for _, decl := range m.bootstrapRequirementDecls { - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "bootstrap_requirement", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - BootstrapRequirements: []BootstrapRequirementSpec{ + BootstrapRequirements: []model.BootstrapRequirementSpec{ { Tool: decl.tool, TargetProjectID: decl.targetProjectID, @@ -177,19 +179,19 @@ func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, er } for _, decl := range m.pythonDependencyDecls { - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "python_dependency", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - RoutedIntents: []RoutedIntentSpec{ + RoutedIntents: []model.RoutedIntentSpec{ { - Kind: IntentPythonDependencyRequest, - Capability: CapabilityPythonPackageManager, + Kind: model.IntentPythonDependencyRequest, + Capability: model.CapabilityPythonPackageManager, TargetProjectID: decl.targetProjectID, Attributes: map[string]string{ - IntentAttrRequirement: decl.requirement, + model.IntentAttrRequirement: decl.requirement, }, }, }, @@ -197,13 +199,13 @@ func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, er } for _, decl := range m.taskDecls { - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "task", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - Tasks: []TaskSpec{ + Tasks: []model.TaskSpec{ { Name: decl.name, TargetProjectID: decl.targetProjectID, @@ -216,13 +218,13 @@ func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, er } for _, decl := range m.taskSurfaceDecls { - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "task_surface", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - TaskSurfaces: []TaskSurfaceSpec{ + TaskSurfaces: []model.TaskSurfaceSpec{ { Name: decl.name, Kind: decl.kind, @@ -233,13 +235,13 @@ func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, er } for _, decl := range m.triggerBindingDecls { - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "trigger_binding", - Module: ModuleSourceCore, - Version: CoreModuleVersion, + Module: model.ModuleSourceCore, + Version: model.CoreModuleVersion, TargetProjectID: decl.targetProjectID, - TriggerBindings: []TriggerBindingSpec{ + TriggerBindings: []model.TriggerBindingSpec{ { Trigger: decl.trigger, TargetProjectID: decl.targetProjectID, @@ -254,7 +256,7 @@ func (m *coreModuleRuntime) BuildComponents(_ DesiredModel) ([]ComponentSpec, er } func (m *coreModuleRuntime) project(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var kind string = ProjectKindGeneric + var kind string = model.ProjectKindGeneric var name string var path string = "." var id string @@ -299,7 +301,7 @@ func (m *coreModuleRuntime) project(_ *starlark.Thread, _ *starlark.Builtin, arg name = defaultProjectName(path, id) } - project := &ProjectSpec{ + project := &model.ProjectSpec{ ID: id, Kind: kind, Name: name, @@ -384,15 +386,15 @@ func (m *coreModuleRuntime) factValue(_ *starlark.Thread, _ *starlark.Builtin, a } func (m *coreModuleRuntime) jsonFile(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - return m.declareStructuredFile("json_file", FileFormatJSON, args, kwargs) + return m.declareStructuredFile("json_file", model.FileFormatJSON, args, kwargs) } func (m *coreModuleRuntime) tomlFile(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - return m.declareStructuredFile("toml_file", FileFormatTOML, args, kwargs) + return m.declareStructuredFile("toml_file", model.FileFormatTOML, args, kwargs) } func (m *coreModuleRuntime) yamlFile(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - return m.declareStructuredFile("yaml_file", FileFormatYAML, args, kwargs) + return m.declareStructuredFile("yaml_file", model.FileFormatYAML, args, kwargs) } func (m *coreModuleRuntime) declareStructuredFile(name string, format string, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { @@ -694,7 +696,7 @@ func defaultProjectName(path string, id string) string { func isKnownSynthesisPhase(value string) bool { switch value { - case SynthesisPhasePre, SynthesisPhaseMain, SynthesisPhasePost: + case model.SynthesisPhasePre, model.SynthesisPhaseMain, model.SynthesisPhasePost: return true default: return false @@ -703,7 +705,7 @@ func isKnownSynthesisPhase(value string) bool { func ensureNoFactRefs(value any) error { switch typed := value.(type) { - case FactValueRef: + case model.FactValueRef: return fmt.Errorf("fact values must not contain fact_value references") case map[string]any: for _, nested := range typed { diff --git a/internal/moltark/module_core_test.go b/internal/module/core_test.go similarity index 97% rename from internal/moltark/module_core_test.go rename to internal/module/core_test.go index 308f807..8a46f75 100644 --- a/internal/moltark/module_core_test.go +++ b/internal/module/core_test.go @@ -1,4 +1,4 @@ -package moltark +package module import ( "testing" diff --git a/internal/moltark/fact_ref_value.go b/internal/module/factref.go similarity index 99% rename from internal/moltark/fact_ref_value.go rename to internal/module/factref.go index 0d08298..8f581f7 100644 --- a/internal/moltark/fact_ref_value.go +++ b/internal/module/factref.go @@ -1,4 +1,4 @@ -package moltark +package module import ( "fmt" diff --git a/internal/module/facts_test.go b/internal/module/facts_test.go new file mode 100644 index 0000000..a1c7a52 --- /dev/null +++ b/internal/module/facts_test.go @@ -0,0 +1,11 @@ +package module + +import "testing" + +func TestResolveModelResolvesFactReferencesInStructuredFiles(t *testing.T) { + // This test exercises fact resolution which lives in the engine package. + // It was originally in moltark/facts_test.go. If it tests module-layer + // config loading, it belongs here; otherwise it may need to move to engine. + // For now it is kept as a placeholder to document the migration. + t.Skip("fact resolution tests migrated to engine/resolve_test.go") +} diff --git a/internal/moltark/pyproject.go b/internal/module/pyproject.go similarity index 53% rename from internal/moltark/pyproject.go rename to internal/module/pyproject.go index dd14343..d9facec 100644 --- a/internal/moltark/pyproject.go +++ b/internal/module/pyproject.go @@ -1,6 +1,20 @@ -package moltark +package module -func pythonProjectFileValues(project ProjectSpec) map[string]any { +import "github.com/ophidiarium/moltark/internal/model" + +var baseOwnedPyprojectPaths = []string{ + "project.name", + "project.version", + "project.requires-python", + "build-system.requires", + "build-system.build-backend", + "tool.moltark.schema-version", + "tool.moltark.template-version", +} + +const uvWorkspaceMembersPath = "tool.uv.workspace.members" + +func pythonProjectFileValues(project model.ProjectSpec) map[string]any { if project.Python == nil { return map[string]any{} } @@ -17,7 +31,7 @@ func pythonProjectFileValues(project ProjectSpec) map[string]any { }, "tool": map[string]any{ "moltark": map[string]any{ - "schema-version": SchemaVersion, + "schema-version": model.SchemaVersion, "template-version": project.Python.TemplateVersion, }, }, diff --git a/internal/moltark/module_python.go b/internal/module/python.go similarity index 68% rename from internal/moltark/module_python.go rename to internal/module/python.go index 591effb..98feaea 100644 --- a/internal/moltark/module_python.go +++ b/internal/module/python.go @@ -1,8 +1,9 @@ -package moltark +package module import ( "fmt" + "github.com/ophidiarium/moltark/internal/model" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" ) @@ -16,26 +17,26 @@ func newPythonModuleRuntime(builder *desiredModelBuilder) localModule { } func (m *pythonModuleRuntime) Namespace() starlark.Value { - return starlarkstruct.FromStringDict(starlark.String(ModuleSourcePython), starlark.StringDict{ + return starlarkstruct.FromStringDict(starlark.String(model.ModuleSourcePython), starlark.StringDict{ "python_project": starlark.NewBuiltin("python_project", m.pythonProject), }) } -func (m *pythonModuleRuntime) BuildComponents(model DesiredModel) ([]ComponentSpec, error) { - components := make([]ComponentSpec, 0, len(model.Projects)) - for _, project := range model.Projects { - if project.Kind != ProjectKindPython { +func (m *pythonModuleRuntime) BuildComponents(desired model.DesiredModel) ([]model.ComponentSpec, error) { + components := make([]model.ComponentSpec, 0, len(desired.Projects)) + for _, project := range desired.Projects { + if project.Kind != model.ProjectKindPython { continue } - components = append(components, ComponentSpec{ + components = append(components, model.ComponentSpec{ ID: m.builder.nextComponentName(), - Kind: ProjectKindPython, - Module: ModuleSourcePython, + Kind: model.ProjectKindPython, + Module: model.ModuleSourcePython, Version: project.Python.TemplateVersion, TargetProjectID: project.ID, - Facts: []FactProviderSpec{ + Facts: []model.FactProviderSpec{ { - Name: FactLanguagePython, + Name: model.FactLanguagePython, ScopeProjectID: project.ID, Values: map[string]any{ "requires_python": project.Python.RequiresPython, @@ -43,10 +44,10 @@ func (m *pythonModuleRuntime) BuildComponents(model DesiredModel) ([]ComponentSp }, }, }, - Files: []StructuredFileSpec{ + Files: []model.StructuredFileSpec{ { - Path: model.projectPyprojectPath(project), - Format: FileFormatTOML, + Path: desired.ProjectPyprojectPath(project), + Format: model.FileFormatTOML, OwnedPaths: append([]string(nil), baseOwnedPyprojectPaths...), UserManagedPaths: []string{"project.dependencies"}, DesiredValues: pythonProjectFileValues(project), @@ -95,19 +96,19 @@ func (m *pythonModuleRuntime) pythonProject(_ *starlark.Thread, _ *starlark.Buil return nil, fmt.Errorf("python_project id %q is already declared", id) } - project := &ProjectSpec{ + project := &model.ProjectSpec{ ID: id, - Kind: ProjectKindPython, + Kind: model.ProjectKindPython, Name: name, Path: path, ParentID: parentID, - Python: &PythonProjectSpec{ + Python: &model.PythonProjectSpec{ Version: version, RequiresPython: requiresPython, - TemplateVersion: PythonTemplateVersion, - BuildSystem: BuildSystem{ - Requires: []string{DefaultBuildRequirement}, - Backend: DefaultBuildBackend, + TemplateVersion: model.PythonTemplateVersion, + BuildSystem: model.BuildSystem{ + Requires: []string{model.DefaultBuildRequirement}, + Backend: model.DefaultBuildBackend, }, }, } diff --git a/internal/moltark/modules.go b/internal/module/registry.go similarity index 64% rename from internal/moltark/modules.go rename to internal/module/registry.go index a91b649..68328bd 100644 --- a/internal/moltark/modules.go +++ b/internal/module/registry.go @@ -1,28 +1,29 @@ -package moltark +package module import ( "fmt" + "github.com/ophidiarium/moltark/internal/model" "go.starlark.net/starlark" ) type localModule interface { Namespace() starlark.Value - BuildComponents(model DesiredModel) ([]ComponentSpec, error) + BuildComponents(model model.DesiredModel) ([]model.ComponentSpec, error) } type localModuleFactory func(*desiredModelBuilder) localModule func (b *desiredModelBuilder) registerLocalModules() { b.moduleFactories = map[string]localModuleFactory{ - ModuleSourcePython: newPythonModuleRuntime, - ModuleSourceUV: newUVModuleRuntime, - ModuleSourceCore: newCoreModuleRuntime, + model.ModuleSourcePython: newPythonModuleRuntime, + model.ModuleSourceUV: newUVModuleRuntime, + model.ModuleSourceCore: newCoreModuleRuntime, } b.moduleBuildOrder = []string{ - ModuleSourcePython, - ModuleSourceUV, - ModuleSourceCore, + model.ModuleSourcePython, + model.ModuleSourceUV, + model.ModuleSourceCore, } } diff --git a/internal/moltark/module_uv.go b/internal/module/uv.go similarity index 61% rename from internal/moltark/module_uv.go rename to internal/module/uv.go index 300dbfd..efd3f88 100644 --- a/internal/moltark/module_uv.go +++ b/internal/module/uv.go @@ -1,8 +1,9 @@ -package moltark +package module import ( "fmt" + "github.com/ophidiarium/moltark/internal/model" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" ) @@ -22,15 +23,15 @@ func newUVModuleRuntime(builder *desiredModelBuilder) localModule { } func (m *uvModuleRuntime) Namespace() starlark.Value { - return starlarkstruct.FromStringDict(starlark.String(ModuleSourceUV), starlark.StringDict{ + return starlarkstruct.FromStringDict(starlark.String(model.ModuleSourceUV), starlark.StringDict{ "uv_workspace": starlark.NewBuiltin("uv_workspace", m.uvWorkspace), }) } -func (m *uvModuleRuntime) BuildComponents(model DesiredModel) ([]ComponentSpec, error) { - components := make([]ComponentSpec, 0, len(m.decls)) +func (m *uvModuleRuntime) BuildComponents(desired model.DesiredModel) ([]model.ComponentSpec, error) { + components := make([]model.ComponentSpec, 0, len(m.decls)) for _, decl := range m.decls { - component, err := m.buildUVWorkspaceComponent(model, decl) + component, err := m.buildUVWorkspaceComponent(desired, decl) if err != nil { return nil, err } @@ -96,71 +97,71 @@ func (m *uvModuleRuntime) uvWorkspace(_ *starlark.Thread, _ *starlark.Builtin, a return starlark.None, nil } -func (m *uvModuleRuntime) buildUVWorkspaceComponent(model DesiredModel, decl uvWorkspaceDecl) (ComponentSpec, error) { - root := model.projectByID(decl.rootProjectID) +func (m *uvModuleRuntime) buildUVWorkspaceComponent(desired model.DesiredModel, decl uvWorkspaceDecl) (model.ComponentSpec, error) { + root := desired.ProjectByID(decl.rootProjectID) if root == nil { - return ComponentSpec{}, fmt.Errorf("uv_workspace root %q is not declared", decl.rootProjectID) + return model.ComponentSpec{}, fmt.Errorf("uv_workspace root %q is not declared", decl.rootProjectID) } memberPaths := make([]string, 0, len(decl.memberIDs)) for _, memberID := range decl.memberIDs { - member := model.projectByID(memberID) + member := desired.ProjectByID(memberID) if member == nil { - return ComponentSpec{}, fmt.Errorf("uv_workspace member %q is not declared", memberID) + return model.ComponentSpec{}, fmt.Errorf("uv_workspace member %q is not declared", memberID) } relativePath, err := relativeWorkspaceMemberPath(root.EffectivePath, member.EffectivePath) if err != nil { - return ComponentSpec{}, fmt.Errorf("uv_workspace member %q: %w", memberID, err) + return model.ComponentSpec{}, fmt.Errorf("uv_workspace member %q: %w", memberID, err) } memberPaths = append(memberPaths, relativePath) } - providers := []CapabilityProvider{ + providers := []model.CapabilityProvider{ { - Capability: CapabilityPythonWorkspaceManager, + Capability: model.CapabilityPythonWorkspaceManager, ScopeProjectID: root.ID, Attributes: map[string]string{ - ProviderAttrManager: "uv", - ProviderAttrFilePath: model.projectPyprojectPath(*root), - ProviderAttrOwnedPath: uvWorkspaceMembersPath, + model.ProviderAttrManager: "uv", + model.ProviderAttrFilePath: desired.ProjectPyprojectPath(*root), + model.ProviderAttrOwnedPath: uvWorkspaceMembersPath, }, }, } for _, projectID := range append([]string{root.ID}, decl.memberIDs...) { - project := model.projectByID(projectID) + project := desired.ProjectByID(projectID) if project == nil { - return ComponentSpec{}, fmt.Errorf("uv_workspace project %q is not declared", projectID) + return model.ComponentSpec{}, fmt.Errorf("uv_workspace project %q is not declared", projectID) } - providers = append(providers, CapabilityProvider{ - Capability: CapabilityPythonPackageManager, + providers = append(providers, model.CapabilityProvider{ + Capability: model.CapabilityPythonPackageManager, ScopeProjectID: project.ID, Attributes: map[string]string{ - ProviderAttrManager: "uv", - ProviderAttrEcosystem: "python", - ProviderAttrFilePath: model.projectPyprojectPath(*project), - ProviderAttrDependencyPath: "project.dependencies", + model.ProviderAttrManager: "uv", + model.ProviderAttrEcosystem: "python", + model.ProviderAttrFilePath: desired.ProjectPyprojectPath(*project), + model.ProviderAttrDependencyPath: "project.dependencies", }, Lists: map[string][]string{ - ProviderListArtifactKinds: {ArtifactKindPyPI}, + model.ProviderListArtifactKinds: {model.ArtifactKindPyPI}, }, }) } - return ComponentSpec{ + return model.ComponentSpec{ ID: m.builder.nextComponentName(), Kind: "uv_workspace", - Module: ModuleSourceUV, - Version: UVModuleVersion, + Module: model.ModuleSourceUV, + Version: model.UVModuleVersion, TargetProjectID: root.ID, Providers: providers, - RoutedIntents: []RoutedIntentSpec{ + RoutedIntents: []model.RoutedIntentSpec{ { - Kind: IntentWorkspaceMembersRequest, - Capability: CapabilityPythonWorkspaceManager, + Kind: model.IntentWorkspaceMembersRequest, + Capability: model.CapabilityPythonWorkspaceManager, TargetProjectID: root.ID, Lists: map[string][]string{ - IntentListMemberPaths: memberPaths, + model.IntentListMemberPaths: memberPaths, }, }, }, diff --git a/internal/moltark/BUILD.bazel b/internal/moltark/BUILD.bazel deleted file mode 100644 index ba8fc8c..0000000 --- a/internal/moltark/BUILD.bazel +++ /dev/null @@ -1,66 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "moltark", - srcs = [ - "config.go", - "constants.go", - "fact_ref_value.go", - "gitattributes.go", - "jsonfile.go", - "module_core.go", - "module_python.go", - "module_uv.go", - "modules.go", - "pipeline.go", - "plan.go", - "pyproject.go", - "resolve.go", - "service.go", - "starlark_convert.go", - "state.go", - "structured_paths.go", - "tomlfile.go", - "types.go", - "yamlfile.go", - ], - importpath = "github.com/ophidiarium/moltark/internal/moltark", - visibility = ["//:__subpackages__"], - deps = [ - "@com_github_burntsushi_toml//:toml", - "@in_gopkg_yaml_v3//:yaml_v3", - "@net_starlark_go//starlark", - "@net_starlark_go//starlarkstruct", - "@net_starlark_go//syntax", - ], -) - -go_test( - name = "moltark_test", - size = "small", # keep - srcs = [ - "config_test.go", - "facts_test.go", - "gitattributes_test.go", - "jsonfile_test.go", - "module_core_test.go", - "pipeline_test.go", - "plan_test.go", - "resolve_test.go", - "service_test.go", - "state_test.go", - "structured_paths_test.go", - "tomlfile_test.go", - "yamlfile_test.go", - ], - data = [ - # keep - "//tests/fixtures:all_fixtures", - ], - embed = [":moltark"], - deps = [ - "//internal/testrepo", - "@com_github_burntsushi_toml//:toml", - "@net_starlark_go//starlark", - ], -) diff --git a/internal/moltark/facts_test.go b/internal/moltark/facts_test.go deleted file mode 100644 index 0297731..0000000 --- a/internal/moltark/facts_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package moltark - -import "testing" - -func TestResolveModelResolvesFactReferencesInStructuredFiles(t *testing.T) { - model := DesiredModel{ - Projects: []ProjectSpec{ - { - ID: "app", - Kind: ProjectKindGeneric, - Name: "app", - Path: ".", - EffectivePath: ".", - }, - }, - Components: []ComponentSpec{ - { - ID: "component_fact", - Kind: "fact", - Module: ModuleSourceCore, - TargetProjectID: "app", - Facts: []FactProviderSpec{ - { - Name: FactLanguageGo, - ScopeProjectID: "app", - Values: map[string]any{ - "version": "1.24", - }, - }, - }, - }, - { - ID: "component_file", - Kind: "toml_file", - Module: ModuleSourceCore, - TargetProjectID: "app", - Files: []StructuredFileSpec{ - { - Path: ".golangci.toml", - Format: FileFormatTOML, - OwnedPaths: []string{"run.go"}, - DesiredValues: map[string]any{ - "run": map[string]any{ - "go": FactValueRef{ - TargetProjectID: "app", - Name: FactLanguageGo, - Path: "version", - }, - }, - }, - }, - }, - }, - }, - } - - resolved, err := ResolveModel(model) - if err != nil { - t.Fatalf("ResolveModel: %v", err) - } - - if len(resolved.ManagedFiles) != 1 { - t.Fatalf("expected 1 managed file, got %d", len(resolved.ManagedFiles)) - } - - got, ok := lookupStructuredValue(resolved.ManagedFiles[0].DesiredValues, FileFormatTOML, "run.go") - if !ok { - t.Fatalf("expected run.go to be resolved") - } - if got != "1.24" { - t.Fatalf("unexpected fact value: got %#v want %q", got, "1.24") - } -} - -func TestResolveModelRejectsAmbiguousFactsAtSameScope(t *testing.T) { - model := DesiredModel{ - Projects: []ProjectSpec{ - { - ID: "app", - Kind: ProjectKindGeneric, - Name: "app", - Path: ".", - EffectivePath: ".", - }, - }, - Components: []ComponentSpec{ - { - ID: "component_fact_1", - Kind: "fact", - Module: ModuleSourceCore, - TargetProjectID: "app", - Facts: []FactProviderSpec{ - { - Name: FactLanguageGo, - ScopeProjectID: "app", - Values: map[string]any{"version": "1.24"}, - }, - }, - }, - { - ID: "component_fact_2", - Kind: "fact", - Module: ModuleSourceCore, - TargetProjectID: "app", - Facts: []FactProviderSpec{ - { - Name: FactLanguageGo, - ScopeProjectID: "app", - Values: map[string]any{"version": "1.25"}, - }, - }, - }, - { - ID: "component_file", - Kind: "toml_file", - Module: ModuleSourceCore, - TargetProjectID: "app", - Files: []StructuredFileSpec{ - { - Path: ".golangci.toml", - Format: FileFormatTOML, - OwnedPaths: []string{"run.go"}, - DesiredValues: map[string]any{ - "run": map[string]any{ - "go": FactValueRef{ - TargetProjectID: "app", - Name: FactLanguageGo, - Path: "version", - }, - }, - }, - }, - }, - }, - }, - } - - if _, err := ResolveModel(model); err == nil { - t.Fatal("expected ambiguous fact resolution to fail") - } -} diff --git a/internal/moltark/resolve_test.go b/internal/moltark/resolve_test.go deleted file mode 100644 index cada7b3..0000000 --- a/internal/moltark/resolve_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package moltark - -import ( - "strings" - "testing" -) - -func TestProjectScopeChainReportsActualMissingAncestor(t *testing.T) { - model := DesiredModel{ - Projects: []ProjectSpec{ - {ID: "leaf", ParentID: "mid"}, - {ID: "mid", ParentID: "missing"}, - }, - } - - _, err := projectScopeChain(model, "leaf") - if err == nil { - t.Fatal("expected missing ancestor to fail") - } - if !strings.Contains(err.Error(), `project parent "missing" is not declared`) { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestProjectWithinSubtreeReportsActualMissingAncestor(t *testing.T) { - model := DesiredModel{ - Projects: []ProjectSpec{ - {ID: "leaf", ParentID: "mid"}, - {ID: "mid", ParentID: "missing"}, - }, - } - - _, err := projectWithinSubtree(model, "leaf", "root") - if err == nil { - t.Fatal("expected missing ancestor to fail") - } - if !strings.Contains(err.Error(), `project parent "missing" is not declared`) { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/testutil/BUILD.bazel b/internal/testutil/BUILD.bazel index 6a5bfff..e9d144b 100644 --- a/internal/testutil/BUILD.bazel +++ b/internal/testutil/BUILD.bazel @@ -7,7 +7,7 @@ go_library( visibility = ["//:__subpackages__"], deps = [ "//internal/cliapp", - "//internal/moltark", + "//internal/model", "//internal/testrepo", ], ) diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 31532d1..fd6d43c 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -11,7 +11,7 @@ import ( "testing" "github.com/ophidiarium/moltark/internal/cliapp" - "github.com/ophidiarium/moltark/internal/moltark" + "github.com/ophidiarium/moltark/internal/model" "github.com/ophidiarium/moltark/internal/testrepo" ) @@ -99,7 +99,7 @@ func RenderRepoState(t *testing.T, root string) string { t.Helper() paths := []string{ - moltark.ProjectSpecFileName, + model.ProjectSpecFileName, ".gitattributes", ".moltark/state.json", } From 84161ae2a3120c56819b24443943549bbb68c8e8 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Mon, 6 Apr 2026 09:24:21 +0200 Subject: [PATCH 2/3] fix: remove placeholder facts_test.go from module package The test was a skip-only placeholder left during migration. The actual fact resolution tests live in internal/engine/resolve_test.go. Addresses: https://github.com/ophidiarium/moltark/pull/14#discussion_r3037542109 --- internal/module/BUILD.bazel | 1 - internal/module/facts_test.go | 11 ----------- 2 files changed, 12 deletions(-) delete mode 100644 internal/module/facts_test.go diff --git a/internal/module/BUILD.bazel b/internal/module/BUILD.bazel index 7be5edf..810af62 100644 --- a/internal/module/BUILD.bazel +++ b/internal/module/BUILD.bazel @@ -28,7 +28,6 @@ go_test( srcs = [ "config_test.go", "core_test.go", - "facts_test.go", ], embed = [":module"], deps = [ diff --git a/internal/module/facts_test.go b/internal/module/facts_test.go deleted file mode 100644 index a1c7a52..0000000 --- a/internal/module/facts_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package module - -import "testing" - -func TestResolveModelResolvesFactReferencesInStructuredFiles(t *testing.T) { - // This test exercises fact resolution which lives in the engine package. - // It was originally in moltark/facts_test.go. If it tests module-layer - // config loading, it belongs here; otherwise it may need to move to engine. - // For now it is kept as a placeholder to document the migration. - t.Skip("fact resolution tests migrated to engine/resolve_test.go") -} From e12aa0d8ffe1725dc6ce66d8088de54ff3495254 Mon Sep 17 00:00:00 2001 From: Konstantin Vyatkin Date: Mon, 6 Apr 2026 09:31:19 +0200 Subject: [PATCH 3/3] fix: normalize backslashes before filepath.Clean in project paths On Unix, filepath.Clean treats backslashes as literal filename characters, not path separators. A path like "pkg\..\..\outside" passes through unchanged and bypasses the escape check. Normalize \ to / before processing so ".." segments are resolved correctly on all platforms. Backslash paths from Windows users (e.g. "sub\dir") are accepted and normalized to forward slashes rather than rejected. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/module/config.go | 5 +++++ internal/module/config_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/module/config.go b/internal/module/config.go index 79bebe0..9305d72 100644 --- a/internal/module/config.go +++ b/internal/module/config.go @@ -232,6 +232,11 @@ func normalizeProjectPath(value string) (string, error) { if value == "" { value = "." } + // Canonicalize to forward slashes before filepath.Clean so that ".." + // segments separated by backslashes are resolved on every platform. + // On Unix filepath.Clean treats '\' as a literal filename character, + // so "pkg\..\..\outside" would pass the escape check untouched. + value = strings.ReplaceAll(value, "\\", "/") if filepath.IsAbs(value) { return "", fmt.Errorf("must be relative") } diff --git a/internal/module/config_test.go b/internal/module/config_test.go index b96c014..3717d2f 100644 --- a/internal/module/config_test.go +++ b/internal/module/config_test.go @@ -9,6 +9,36 @@ import ( "github.com/ophidiarium/moltark/internal/model" ) +func TestNormalizeProjectPathNormalizesBackslashes(t *testing.T) { + got, err := normalizeProjectPath(`sub\dir`) + if err != nil { + t.Fatalf("normalizeProjectPath(%q): unexpected error: %v", `sub\dir`, err) + } + if got != "sub/dir" { + t.Errorf("normalizeProjectPath(%q) = %q, want %q", `sub\dir`, got, "sub/dir") + } +} + +func TestNormalizeProjectPathRejectsEscapes(t *testing.T) { + cases := []string{ + "..", + "../outside", + "pkg/../../outside", + `pkg\..\..\outside`, + `pkg\..\..\..\outside`, + } + for _, input := range cases { + _, err := normalizeProjectPath(input) + if err == nil { + t.Errorf("normalizeProjectPath(%q): expected error, got nil", input) + continue + } + if !strings.Contains(err.Error(), "must not escape") { + t.Errorf("normalizeProjectPath(%q): got %q, want escape error", input, err) + } + } +} + func TestLoadDesiredModelRejectsModuleVersions(t *testing.T) { root := t.TempDir() content := `python = use("moltark/python", version = "v0.1.0")