Manifest & Lockfile Specification

The absolute structural specification for deft.toml and deft.lock, drawn directly from the serde data model in manifest.rs and the layout rules in engine.rs.

deft.toml Spec

The root deserialization target is Manifest:

pub struct Manifest {
    pub workspace: Option<Workspace>,
    pub package: Option<Package>,
    pub features: BTreeMap<String, Vec<String>>,
    pub profile: Profiles,
    pub dependencies: BTreeMap<String, Dependency>,
}

Every top-level table is optional at the parse level (#[serde(default)] on all but package, and package itself is Option<Package>) — a manifest with none of these tables still parses successfully. package is only required at the point a package is actually built: require_package in engine.rs turns a missing [package] table into DeftError::ManifestParse { message: "missing [package] table (name/version required to build)" }. This split lets a workspace root manifest declare only [workspace] with no [package] of its own.

[package]

[package]
name = "my_project"
version = "0.2.0"
description = "optional"
authors = ["optional", "list"]
pub struct Package {
    pub name: String,         // required
    pub version: String,      // required
    pub description: Option<String>,  // default: None
    pub authors: Vec<String>,         // default: []
}

name and version have no #[serde(default)] — both are mandatory once a [package] table is present at all.

[workspace]

[workspace]
members = ["app", "lib/core"]
pub struct Workspace {
    pub members: Vec<String>,  // default: []
}

Manifest::is_workspace() returns true only when workspace is Some and members is non-empty — a [workspace] table with an empty or absent members list is treated as not a workspace at all. Each member path is resolved relative to the workspace root and must itself be a complete deft-standard package (own deft.toml, own src/ layout) — see cli.md for the build-order semantics.

[features]

[features]
default = ["ssl"]
ssl = ["tls"]
tls = []

Modeled directly as BTreeMap<String, Vec<String>> — there is no dedicated Feature struct. Each key is a feature name; each value is the list of other feature names it implies. The conventional default key, if present, is the seed set activated unless --no-default-features is passed. See Feature Flag Resolution below for the expansion algorithm.

[profile.c] and [profile.cpp]

[profile.c]
standard = "c17"           # default: "c17"
warnings = ["all", "extra"] # default: []
optimization = "0"          # default: "0"
extra_flags = []             # default: []
defines = []                  # default: []

[profile.cpp]
standard = "c++20"          # default: "c++20"
rtti = false                  # default: true
exceptions = true             # default: true
warnings = ["all", "extra"] # default: []
optimization = "0"          # default: "0"
extra_flags = []             # default: []
defines = []                  # default: []

Profiles is { c: Option<CProfile>, cpp: Option<CppProfile>, ... #[serde(default)] } — a manifest may declare neither, either, or both profile tables. A package only needs the profile matching its own entry language; an absent table is filled in with CProfile::default() / CppProfile::default() at build time (manifest.profile.c.clone() .unwrap_or_default() in main.rs), so omitting [profile.c] entirely from a C++ package’s manifest is normal and harmless.

optimization is a free-form string at the manifest level, validated lazily by OptLevel::parse (compiler.rs) only at build time — accepted values are "0", "1", "2", "3", "s"/"size", "z"/"tiny", "g"/"debug", "fast". An unrecognized value produces DeftError::Config before any compilation begins (Compiler::validate() is called up front in Engine::build_package), not mid-build.

warnings entries map through warning_flag() (compiler.rs): "all"-Wall, "extra"-Wextra, "error"-Werror, "pedantic"-Wpedantic, "everything"-Weverything; any other string kw passes through verbatim as -W<kw>, so any clang warning group can be named without deft needing an exhaustive table.

extra_flags is an escape hatch: each string is appended to the clang invocation verbatim, after all other deft-managed flags, normally left empty.

defines entries become -D<entry> flags, alongside the -D flags deft synthesizes itself for active features (see below) — both sets are merged in push_common.

rtti and exceptions exist only on CppProfile — there is no C equivalent, since these are C++ language features. false maps to -fno-rtti/-fno-exceptions; true (the default for both) maps to -frtti/-fexceptions. This field-level asymmetry between CProfile and CppProfile is itself part of deft’s compiler boundary isolation — see architecture.md.

[dependencies]

[dependencies]
"gh:user/http_parser" = "1.5"
"gh:another/ssl" = { version = "2.1", features = ["ssl"], tag = "v2.1.0" }

Keys are shorthand strings (conventionally gh:user/lib for GitHub, resolved through Resolver::map_shorthand — see resolver.rs). Values deserialize into Dependency:

pub struct Dependency {
    pub version: String,
    pub features: Vec<String>,
    pub tag: Option<String>,
}

Untagged shorthand-vs-table deserialization. Dependency implements Deserialize by hand (not via derive) specifically to accept either a bare string or a detailed table in the same map value position:

#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
    Simple(String),
    Detailed {
        version: String,
        #[serde(default)] features: Vec<String>,
        #[serde(default)] tag: Option<String>,
    },
}

serde’s #[serde(untagged)] attempts each enum variant in declaration order without a discriminant field, so a plain TOML string value ("gh:user/lib" = "1.5") matches Raw::Simple (it’s just a string), while an inline table ({ version = "2.1", features = [...] }) matches Raw::Detailed (it has the shape of a map with a required version key). Both variants are then normalized into the same Dependency struct — Simple gets empty features and tag: None. This lets a manifest author write the terse form for the common case (just a version) and only reach for the table form when they need features or an explicit tag override (used when the git tag differs from the semantic version string).

Feature Flag Resolution

Manifest::resolve_features(requested: &[String], no_default: bool) -> Vec<String> computes the final, transitively-expanded set of active features and is the single place feature logic lives:

pub fn resolve_features(&self, requested: &[String], no_default: bool) -> Vec<String> {
    let mut enabled: Vec<String> = Vec::new();
    let mut stack: Vec<String> = Vec::new();

    if !no_default {
        if let Some(defaults) = self.features.get("default") {
            stack.extend(defaults.iter().cloned());
        }
    }
    stack.extend(requested.iter().cloned());

    while let Some(feature) = stack.pop() {
        if enabled.iter().any(|f| f == &feature) {
            continue;
        }
        enabled.push(feature.clone());
        if let Some(implied) = self.features.get(&feature) {
            stack.extend(implied.iter().cloned());
        }
    }

    enabled.sort();
    enabled.dedup();
    enabled
}

Algorithm: this is a depth-first, stack-based transitive closure over the implication graph encoded by [features]. The seed stack is the union of (a) the default feature’s list, unless --no-default-features was passed, and (b) the --features a,b,c list from the CLI. The loop pops one feature name at a time; if it’s already in enabled, it’s skipped (cycle/duplicate protection — the graph is allowed to have diamonds or even cycles without infinite-looping, since each name is only ever pushed onto enabled once); otherwise it’s recorded, and its own implied features (its entry in the [features] map) are pushed onto the stack to be expanded in turn. The final result is sorted and deduplicated for deterministic, order-independent output.

Compile-time macros. Each member of the resolved feature set becomes a preprocessor define via Compiler::new:

let feature_defines = active_features
    .iter()
    .map(|f| format!("DEFT_FEATURE_{}", f.to_ascii_uppercase().replace('-', "_")))
    .collect();

So a feature named ssl-support becomes -DDEFT_FEATURE_SSL_SUPPORT, injected into every translation unit’s compile command alongside the profile’s own defines (push_common in compiler.rs). There is no per-feature include path or source-file gating beyond this macro — conditional compilation inside sources (#ifdef DEFT_FEATURE_SSL_SUPPORT) is the mechanism by which a feature actually changes behavior.

Dependencies currently resolve their own default feature set independently (dep_manifest.resolve_features(&[], false) in main.rs build_dependencies) — a consuming package’s --features selection does not yet propagate into its dependencies’ builds.

deft.lock Spec

pub struct Lockfile {
    #[serde(rename = "dependency")]
    pub dependencies: Vec<LockedDependency>,
}

pub struct LockedDependency {
    pub name: String,        // bare package name
    pub source: String,      // "git+<url>"
    pub checksum: String,    // exact resolved commit SHA
    pub version: String,     // requested version/tag string
    pub dependencies: Vec<String>,  // direct dependency names (graph edges)
}

On disk this serializes (via #[serde(rename = "dependency")]) as a flat array of TOML tables:

# This file is auto-generated by deft.
# It records exact resolved versions for reproducible builds.
# Do not edit by hand; run `deft update` to regenerate.

[[dependency]]
name = "http_parser"
source = "git+https://github.com/user/http_parser.git"
checksum = "a1b2c3d4e5f6..."
version = "1.5"
dependencies = []

Entries are sorted by name before serialization (Lockfile::save sorts a cloned copy), so the file’s diff stays stable across re-locks regardless of the manifest’s [dependencies] declaration order (the manifest side is already a BTreeMap, so it’s naturally key-sorted; the explicit lock-side sort makes the lock’s ordering independent of any future change to that).

Role in reproducible builds. deft build calls resolver.resolve_all(manifest, existing_lock.as_ref()). When a lock entry exists for a dependency and its recorded version still matches the manifest’s currently-declared version, Resolver::resolve_one takes the “reproducible path”: it hard-resets the cached checkout to the locked checksum via git checkout --quiet <sha> (fetching --unshallow first if the SHA isn’t present locally) rather than reading the tag’s current HEAD. Only when there is no lock entry, or the manifest’s version string has changed, does resolution fall through to head_sha() (fresh HEAD of the requested tag). This is the mechanism that makes deft build immutable across machines and over time as long as deft.lock is committed: two checkouts of the same repository with the same deft.lock resolve every dependency to the exact same commit, never a possibly-moved tag.

If no lock exists yet, the very first successful deft build writes one (build_single in main.rs): if existing_lock.is_none() && !resolved.is_empty() { ... lock.save(root)?; } — first-build lock creation is implicit; subsequent builds never silently rewrite the lock on their own (only deft update does that intentionally).

Atomic write pattern. Lockfile::save:

let tmp = path.with_extension("lock.tmp");
fs::write(&tmp, format!("{header}{body}")).path_ctx(&tmp)?;
fs::rename(&tmp, &path).path_ctx(&path)?;

The full serialized contents (including the three-line auto-generated-file header comment) are written to a sibling deft.lock.tmp first, and only the final fs::rename touches the real deft.lock path. rename within the same filesystem is atomic at the OS level, so a process interrupted mid-write (crash, killed build, disk full) can only ever leave a stale-but-intact .tmp file behind — the live deft.lock is either the previous complete version or the new complete version, never a half-written file. This exact pattern (write .tmp, then rename) is reused for the ~/.deft/deft-libs package index in deft sync — see cli.md.

Directory Layout Standards

Layout::discover (engine.rs) enforces the four canonical entry file priority queue — deft never globs src/ looking for “a” main file; it checks for exactly these four paths, in this exact precedence order, and uses the first one found:

PriorityPathCrate kindLanguage
1src/main.cppExecutableC++
2src/main.cExecutableC
3src/lib.cppLibraryC++
4src/lib.cLibraryC

An executable entry (main.*) always wins over a library entry (lib.*) if, somehow, both exist in the same src/ — the doc comment in engine.rs states the rationale plainly: “a package with main.* is runnable.” If none of the four exist, the build fails immediately with a DeftError::LayoutViolation enumerating all four expected paths — this check happens before the manifest’s [profile.*] is even consulted, since there is nothing to compile without a discovered entry point.

Strict single-language enforcement. Once the entry file fixes the package’s entry_language, Layout::collect_sources recursively walks src/ and partitions every recognized source file (anything Language::from_extension returns Some for) into either sources (matching entry_language) or foreign (the other language). Headers and unrecognized extensions are silently skipped — they’re not translation units either way. If foreign is non-empty, the entire build is rejected with a DeftError::LayoutViolation naming the offending language, the count of foreign files, and one example path:

strict C/C++ separation violated: this is a Cpp package but found 2 C
source file(s) (e.g. 'src/legacy/util.c'). A deft package is single-language.

There is no per-file override or escape hatch for this rule within a single package — a project needing both languages must be split into separate deft-standard packages (e.g. a workspace with one C member and one C++ member), each independently satisfying the four-file/single-language rule. See migration.md for how deft migrate handles pre-existing mixed-language CMake projects under this same constraint.