Skip to content

feat(sync): add --local-file output target#317

Merged
jdx merged 12 commits intojdx:mainfrom
florian-lackner365:feat/sync-local-file
Mar 10, 2026
Merged

feat(sync): add --local-file output target#317
jdx merged 12 commits intojdx:mainfrom
florian-lackner365:feat/sync-local-file

Conversation

@florian-lackner365
Copy link
Copy Markdown
Contributor

Add sync flag to write generated sync overrides into fnox.local.toml next to the active config path, with --global conflict guard.

Update sync docs/usage and add regression tests for local-file write path + custom --config directory behavior.

closes #316

Add sync flag to write generated sync overrides into fnox.local.toml next to the active config path, with --global conflict guard.

Update sync docs/usage and add regression tests for local-file write path + custom --config directory behavior.
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the sync command by introducing a --local-file flag, enabling users to store synchronized secret overrides in a dedicated fnox.local.toml file within the active configuration directory. This provides greater flexibility in managing configuration overrides, separating them from the main configuration. The change includes necessary updates to command-line documentation and comprehensive regression tests to validate the new functionality, including its interaction with custom configuration paths and its designed conflict with the --global flag.

Highlights

  • New sync flag: Introduced --local-file for the sync command.
  • Local override storage: Allows writing sync overrides to fnox.local.toml in the config directory, next to the active config path.
  • Conflict prevention: The --local-file flag conflicts with the --global flag to prevent unintended behavior.
  • Documentation & Tests: Updated CLI documentation and added regression tests for the new functionality and custom config directory behavior.
Changelog
  • docs/cli/commands.json
    • Added the definition for the new --local-file flag.
  • docs/cli/sync.md
    • Updated the sync command documentation to include the --local-file flag.
  • fnox.usage.kdl
    • Integrated the --local-file flag into the sync command's usage specification.
  • src/commands/sync.rs
    • Implemented the --local-file argument.
    • Updated the logic for determining the target configuration file path.
    • Modified output messages to reflect the chosen destination.
  • test/sync.bats
    • Added new test cases to verify the correct behavior of --local-file, including writing to fnox.local.toml.
    • Added tests for handling custom config directories.
    • Ensured it conflicts with --global.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a --local-file flag for the sync command, which directs sync overrides to a fnox.local.toml file. This is a well-implemented feature that improves local development workflows. The changes are comprehensive, including updates to the CLI logic, documentation, and the addition of thorough regression tests. My review found a minor code duplication in src/commands/sync.rs for constructing the output message suffix, and I've provided a suggestion to refactor it for better maintainability. Otherwise, the changes look good.

Comment thread src/commands/sync.rs Outdated
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 6, 2026

Greptile Summary

This PR adds a --local-file flag to fnox sync that writes synced secret overrides into fnox.local.toml (or .fnox.local.toml) next to the active config file, rather than modifying the config file itself. This lets teams commit fnox.toml with provider declarations while keeping the encrypted sync cache in a gitignored local-only file. The flag conflicts with --global and is restricted to the two supported base config filenames.

Key changes:

  • src/commands/sync.rs: Adds --local-file field to SyncCommand; computes effective_config_path via find_local_config to detect the active config, validates the basename via local_override_filename, and routes writes to <config_dir>/fnox.local.toml. The destination_suffix string is now shared between the dry-run display and the final success message.
  • src/config.rs: Extracts the new public helper local_override_filename(path) -> Option<&'static str> and adds corresponding unit tests.
  • test/sync.bats: Adds 7 regression tests covering dry-run output, write + round-trip, .fnox.toml variant, unsupported filename rejection, parent-dir safety, and --global conflict.
  • Docs (sync.md, hierarchical-config.md, commands.json, fnox.usage.kdl): Flag documented in all three help surfaces; the limitation to fnox.toml/.fnox.toml is noted in the guide.

Style notes (not blocking):

  • The let local_override_filename binding at line 67 of sync.rs shadows the imported function of the same name, which is a maintainability hazard for future refactors.
  • find_local_config (filesystem I/O) is invoked on every fnox sync call, even when --local-file is absent; guarding the block would avoid the unnecessary I/O.
  • The unit test test_local_override_filename_matches_standard_config_names includes a nested/fnox.toml path, which could mislead future readers into thinking directory-prefixed paths are fully supported.

Confidence Score: 4/5

  • Safe to merge with minor style cleanup; no logic or data-loss bugs introduced in the happy path.
  • The core write-and-read-back flow is correct: find_local_config picks up the right config, the basename guard prevents writes to unsupported filenames, create_dir_all is correctly scoped to --local-file and --global only, and seven new tests cover the critical paths including dry-run, .fnox.toml variant, rejection of non-standard names, and the --global conflict. The remaining concerns (variable shadowing, unconditional I/O, slightly misleading test name) are style-level and do not affect correctness.
  • src/commands/sync.rs — variable shadowing and unconditional find_local_config I/O are worth addressing before the next round of refactoring.

Important Files Changed

Filename Overview
src/commands/sync.rs Core implementation of --local-file; mostly correct, but the local_override_filename variable shadows the imported function of the same name, and effective_config_path (including find_local_config I/O) is computed unconditionally on every fnox sync invocation.
src/config.rs New local_override_filename helper is clean and well-documented; unit tests added. Test name test_local_override_filename_matches_standard_config_names is slightly misleading since it includes a nested/ prefix path.
test/sync.bats Good test coverage: dry-run marker, local-file write, .fnox.toml variant, round-trip, conflict guard, and parent-dir regression test all added. The --config directory test isolates via cd rather than a directory-prefixed flag, so --config nested/fnox.toml (path with component) remains untested.
docs/cli/sync.md Flag documented correctly; the supported-filename constraint is mentioned in the guide but not in the command reference itself.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI
    participant SyncCommand
    participant Config
    participant FS

    User->>CLI: fnox sync --local-file [--config path]
    CLI->>Config: load_smart(cli.config)
    Config-->>CLI: merged_config

    CLI->>SyncCommand: run(cli, merged_config)

    SyncCommand->>Config: find_local_config(cwd, profile)
    Config->>FS: path.exists() checks (up to 6)
    FS-->>Config: candidate path
    Config-->>SyncCommand: effective_config_path

    SyncCommand->>Config: local_override_filename(effective_config_path)
    alt basename is fnox.toml or .fnox.toml
        Config-->>SyncCommand: Some("fnox.local.toml" / ".fnox.local.toml")
    else unsupported filename
        Config-->>SyncCommand: None → Error returned
    end

    SyncCommand->>SyncCommand: filter & resolve secrets

    alt --dry-run
        SyncCommand-->>User: "[dry-run] Would sync N secrets ... (local-file)"
    else write path
        SyncCommand->>FS: create_dir_all(config_dir)
        SyncCommand->>FS: write synced secrets to fnox.local.toml
        SyncCommand-->>User: "Synced N secrets ... (local-file)"
    end

    User->>CLI: fnox get SECRET
    CLI->>Config: load_with_recursion(cwd)
    Config->>FS: load fnox.toml (secret definition)
    Config->>FS: load fnox.local.toml (sync cache)
    Config-->>CLI: merged config with sync overrides
    CLI-->>User: decrypted secret value
Loading

Last reviewed commit: 5ce3b1e

Comment thread test/sync.bats
Comment thread test/sync.bats
Comment thread src/commands/sync.rs Outdated
Comment thread src/commands/sync.rs
Comment thread src/commands/sync.rs Outdated
Comment thread src/commands/sync.rs
Comment thread src/config.rs
Comment on lines +1628 to +1636
fn test_local_override_filename_matches_standard_config_names() {
assert_eq!(
local_override_filename(Path::new("nested/fnox.toml")),
Some("fnox.local.toml")
);
assert_eq!(
local_override_filename(Path::new("nested/.fnox.toml")),
Some(".fnox.local.toml")
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name claims "standard config names" but includes a non-standard path

test_local_override_filename_matches_standard_config_names asserts that nested/fnox.toml returns Some("fnox.local.toml"). The function itself behaves correctly (it only inspects the basename), but nested/fnox.toml is not a standard config path — it is a path with a directory prefix that, when passed to load_smart, takes the else branch and loads only the explicit file, meaning the adjacent fnox.local.toml is never picked up.

Including this case under "standard config names" may give future readers the false impression that this path is a supported input for the --local-file feature. Consider moving it to a separate test named something like test_local_override_filename_basename_only_match and pairing it with a comment that makes the limitation explicit:

#[test]
fn test_local_override_filename_basename_only_match() {
    // The function inspects only the basename. Note that paths with directory
    // components (e.g. "nested/fnox.toml") are technically accepted here but
    // the --local-file feature is NOT supported for such paths because
    // load_smart does not pick up the adjacent local override file.
    assert_eq!(
        local_override_filename(Path::new("nested/fnox.toml")),
        Some("fnox.local.toml")
    );
}

Comment thread test/sync.bats
Comment thread src/commands/sync.rs
Comment thread test/sync.bats
Comment thread docs/cli/sync.md
Comment on lines +41 to +43
### `--local-file`

Write sync overrides to the local override file next to the config file
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--local-file help text omits critical constraint

The flag's help text only says "Write sync overrides to the local override file next to the config file", but it silently accepts paths like --config nested/fnox.toml (the basename check in local_override_filename passes), writes nested/fnox.local.toml, yet those secrets are never read back because load_smart takes the direct-load path for any path with directory components.

The constraint that only bare fnox.toml / .fnox.toml config filenames are supported is documented in docs/guide/hierarchical-config.md, but a user reading the command reference first will see no warning. The same omission is present in docs/cli/commands.json and the fnox.usage.kdl help string.

Consider adding the restriction to all three help strings, e.g.:

Suggested change
### `--local-file`
Write sync overrides to the local override file next to the config file
### `--local-file`
Write sync overrides to the local override file (`fnox.local.toml` / `.fnox.local.toml`) next to the config file. Only supported when the active config basename is `fnox.toml` or `.fnox.toml`.

Comment thread src/commands/sync.rs
Comment on lines +52 to +65
let effective_config_path =
if cli.config == std::path::Path::new(config::DEFAULT_CONFIG_FILENAME) {
let current_dir = std::env::current_dir().map_err(|e| {
FnoxError::Config(format!("Failed to get current directory: {}", e))
})?;
let candidate = config::find_local_config(&current_dir, Some(&profile));
if local_override_filename(&candidate).is_some() {
candidate
} else {
cli.config.clone()
}
} else {
cli.config.clone()
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

effective_config_path computed unconditionally regardless of --local-file

find_local_config performs filesystem I/O (up to ~6 path.exists() calls) on every fnox sync invocation even when --local-file is not set. effective_config_path is only consumed inside the two self.local_file-gated branches (lines 67–77 and 241–251), so this work is entirely wasted for the common case.

Consider guarding the block:

let effective_config_path = if self.local_file {
    if cli.config == std::path::Path::new(config::DEFAULT_CONFIG_FILENAME) {
        let current_dir = std::env::current_dir().map_err(|e| {
            FnoxError::Config(format!("Failed to get current directory: {}", e))
        })?;
        let candidate = config::find_local_config(&current_dir, Some(&profile));
        if local_override_filename(&candidate).is_some() {
            candidate
        } else {
            cli.config.clone()
        }
    } else {
        cli.config.clone()
    }
} else {
    cli.config.clone() // unused, but avoids Option wrapping
};

Comment thread src/commands/sync.rs
Comment on lines +67 to +77
let local_override_filename = self
.local_file
.then(|| {
local_override_filename(&effective_config_path).ok_or_else(|| {
FnoxError::Config(format!(
"--local-file requires --config to be 'fnox.toml' or '.fnox.toml'; '{}' would not load the adjacent local override file",
effective_config_path.display()
))
})
})
.transpose()?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

local_override_filename variable shadows imported function

The let local_override_filename binding on line 67 shadows the local_override_filename function imported at line 2. Rust resolves name lookups in scope order, so any call to local_override_filename(...) after this point within run() would resolve to the Option<&'static str> value, not the function — resulting in a type error instead of a call. It doesn't cause a runtime bug today (the function isn't called again after line 77), but it's a maintainability hazard: a future refactor that calls the helper again after this point would get a confusing compile error rather than the expected behaviour.

Consider renaming the local binding to avoid the collision:

Suggested change
let local_override_filename = self
.local_file
.then(|| {
local_override_filename(&effective_config_path).ok_or_else(|| {
FnoxError::Config(format!(
"--local-file requires --config to be 'fnox.toml' or '.fnox.toml'; '{}' would not load the adjacent local override file",
effective_config_path.display()
))
})
})
.transpose()?;
let local_override_target = self
.local_file
.then(|| {
local_override_filename(&effective_config_path).ok_or_else(|| {
FnoxError::Config(format!(
"--local-file requires --config to be 'fnox.toml' or '.fnox.toml'; '{}' would not load the adjacent local override file",
effective_config_path.display()
))
})
})
.transpose()?;

Then update the later usages at lines 249 and 307 accordingly (e.g. local_override_target.expect(...)).

Comment thread src/commands/sync.rs
Comment on lines +52 to +65
let effective_config_path =
if cli.config == std::path::Path::new(config::DEFAULT_CONFIG_FILENAME) {
let current_dir = std::env::current_dir().map_err(|e| {
FnoxError::Config(format!("Failed to get current directory: {}", e))
})?;
let candidate = config::find_local_config(&current_dir, Some(&profile));
if local_override_filename(&candidate).is_some() {
candidate
} else {
cli.config.clone()
}
} else {
cli.config.clone()
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

effective_config_path computed unconditionally

find_local_config performs up to ~6 path.exists() filesystem calls on every fnox sync invocation, even when --local-file is not set. effective_config_path is only consumed inside the two self.local_file-gated branches (lines 67–77 and 241–251), so this I/O is entirely wasted for the common case.

Wrapping the block in an if self.local_file { ... } guard would make it a no-op unless the flag is actually passed:

let effective_config_path = if self.local_file {
    if cli.config == std::path::Path::new(config::DEFAULT_CONFIG_FILENAME) {
        let current_dir = std::env::current_dir().map_err(|e| {
            FnoxError::Config(format!("Failed to get current directory: {}", e))
        })?;
        let candidate = config::find_local_config(&current_dir, Some(&profile));
        if local_override_filename(&candidate).is_some() {
            candidate
        } else {
            cli.config.clone()
        }
    } else {
        cli.config.clone()
    }
} else {
    PathBuf::new() // unused; never accessed when local_file is false
};

Comment thread src/config.rs
Comment on lines +1627 to +1637
#[test]
fn test_local_override_filename_matches_standard_config_names() {
assert_eq!(
local_override_filename(Path::new("nested/fnox.toml")),
Some("fnox.local.toml")
);
assert_eq!(
local_override_filename(Path::new("nested/.fnox.toml")),
Some(".fnox.local.toml")
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading test name includes a non-standard path

test_local_override_filename_matches_standard_config_names asserts that nested/fnox.toml returns Some("fnox.local.toml"). The function itself is correct (it only inspects the basename), but nested/fnox.toml is not a standard config path in the context of --local-file — it has a directory component that causes load_smart to take the direct-load branch and ignore adjacent local override files.

Including this case under "standard config names" may give future readers the false impression that paths with directory components are fully supported. Consider moving it to a dedicated test that makes the basename-only match behaviour explicit:

#[test]
fn test_local_override_filename_basename_only_match() {
    // The function inspects only the basename. Paths with directory components
    // are accepted here but are NOT valid --local-file inputs at the CLI level
    // because load_smart would not load the adjacent fnox.local.toml for them.
    assert_eq!(
        local_override_filename(Path::new("nested/fnox.toml")),
        Some("fnox.local.toml")
    );
}

@florian-lackner365
Copy link
Copy Markdown
Contributor Author

@jdx the PR is ready for manual review I'd say 👍

@jdx jdx merged commit 2ff0623 into jdx:main Mar 10, 2026
14 checks passed
jdx pushed a commit that referenced this pull request Mar 13, 2026
### 🚀 Features

- **(sync)** add --local-file output target by
[@florian-lackner365](https://github.com/florian-lackner365) in
[#317](#317)

### 🐛 Bug Fixes

- properly handle auth prompt in batch providers by
[@johnpyp](https://github.com/johnpyp) in
[#349](#349)

### 🛡️ Security

- **(mcp)** redact secret values from exec output by
[@jdx](https://github.com/jdx) in
[#357](#357)

### 📦️ Dependency Updates

- lock file maintenance by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#344](#344)
- update jdx/mise-action digest to 5228313 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#351](#351)
- update swatinem/rust-cache digest to e18b497 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#352](#352)
- update taiki-e/upload-rust-binary-action digest to 381995c by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#353](#353)
- update dependency vue to v3.5.30 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#354](#354)
- update rust crate openssl-sys to v0.9.112 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#355](#355)
- update rust crate clap to v4.6.0 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#356](#356)

### New Contributors

- @florian-lackner365 made their first contribution in
[#317](#317)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants