Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
env:
CARGO_TERM_COLOR: always
BUILD_PROFILE: debug
WHITAKER_INSTALLER_REV: f768c2e53c47df13658af1168a67851d388750bf
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Setup Rust
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Expand All @@ -35,8 +36,27 @@ jobs:
!**/dist/**
- name: Setup uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78
- name: Cache Whitaker installation
id: cache-whitaker
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
with:
path: |
~/.cargo/bin/whitaker-installer
~/.local/bin/whitaker
~/.dylint_drivers
~/.local/share/whitaker
key: whitaker-v2-${{ runner.os }}-${{ env.WHITAKER_INSTALLER_REV }}
- name: Script tests
run: make test-scripts
- name: Install Whitaker
run: |
if [ "${{ steps.cache-whitaker.outputs.cache-hit }}" != "true" ]; then
cargo install --locked \
--git https://github.com/leynos/whitaker \
--rev "${WHITAKER_INSTALLER_REV}" \
whitaker-installer
fi
whitaker-installer --cranelift
- name: Lint
run: make lint
- name: Test and Measure Coverage
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,15 +215,15 @@ project:
### Dependency Management

- **Mandate caret requirements for all dependencies.** All crate versions
specified in `Cargo.toml` must use SemVer-compatible caret requirements
(e.g., `some-crate = "1.2.3"`). This is Cargo's default and allows for safe,
specified in `Cargo.toml` must use SemVer-compatible caret requirements (e.g.,
`some-crate = "1.2.3"`). This is Cargo's default and allows for safe,
non-breaking updates to minor and patch versions while preventing breaking
changes from new major versions. This approach is critical for ensuring build
stability and reproducibility.
- **Prohibit unstable version specifiers.** The use of wildcard (`*`) or
open-ended inequality (`>=`) version requirements is strictly forbidden, as
they introduce unacceptable risk and unpredictability. Tilde requirements
(`~`) should only be used where a dependency must be locked to patch-level
they introduce unacceptable risk and unpredictability. Tilde requirements (
`~`) should only be used where a dependency must be locked to patch-level
updates for a specific, documented reason.

### Error Handling
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

TARGET ?= dear-diary

USER_WHITAKER := $(HOME)/.local/bin/whitaker
USER_BIN_PATH := $(HOME)/.cargo/bin:$(HOME)/.local/bin:$(HOME)/.bun/bin
CARGO ?= cargo
BUILD_JOBS ?=
RUST_FLAGS ?= -D warnings
Expand All @@ -13,6 +15,7 @@ TEST_FLAGS ?= $(CARGO_FLAGS)
MDLINT ?= markdownlint-cli2
NIXIE ?= nixie
PYTEST ?= uv run --with pytest --with cyclopts --with syrupy python -m pytest
WHITAKER ?= $(or $(shell command -v whitaker 2>/dev/null),$(wildcard $(USER_WHITAKER)),whitaker)

build: target/debug/$(TARGET) ## Build debug binary
release: target/release/$(TARGET) ## Build release binary
Expand All @@ -34,6 +37,7 @@ target/%/$(TARGET): ## Build binary in debug or release mode
lint: ## Run Clippy with warnings denied
RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --workspace --no-deps
$(CARGO) clippy $(CLIPPY_FLAGS)
PATH="$(USER_BIN_PATH):$(PATH)" RUSTFLAGS="$(RUST_FLAGS)" $(WHITAKER) --all -- $(CARGO_FLAGS)

fmt: ## Format Rust and Markdown sources
$(CARGO) fmt --all
Expand Down
199 changes: 150 additions & 49 deletions crates/dear-diary-config/src/interpolation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,70 @@ mod tests;
use crate::error::ConfigError;
use parse::parse_remote_url;

/// Tracks which interpolation placeholders appear in a template.
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
struct PlaceholderNeeds {
remote: RemotePlaceholderNeeds,
local: LocalPlaceholderNeeds,
}

/// Tracks placeholders resolved from git remote metadata.
struct RemotePlaceholderNeeds {
repo: bool,
owner: bool,
}

/// Tracks placeholders resolved from local repository state.
struct LocalPlaceholderNeeds {
cwd: bool,
branch: bool,
}

impl PlaceholderNeeds {
/// Builds placeholder requirements by scanning the template once per token.
fn from_template(template: &str) -> Self {
Self {
remote: RemotePlaceholderNeeds {
repo: template.contains("{repo}"),
owner: template.contains("{owner}"),
},
local: LocalPlaceholderNeeds {
cwd: template.contains("{cwd}"),
branch: template.contains("{branch}"),
},
}
}

/// Returns true when the template contains no supported placeholders.
const fn is_empty(&self) -> bool {
!self.needs_remote() && !self.needs_cwd() && !self.needs_branch()
}

/// Returns true when interpolation must inspect the git remote.
const fn needs_remote(&self) -> bool {
self.remote.repo || self.remote.owner
}

/// Returns true when the repository placeholder appears in the template.
const fn needs_repo(&self) -> bool {
self.remote.repo
}

/// Returns true when the owner placeholder appears in the template.
const fn needs_owner(&self) -> bool {
self.remote.owner
}

/// Returns true when the working-directory placeholder appears.
const fn needs_cwd(&self) -> bool {
self.local.cwd
}

/// Returns true when the branch placeholder appears in the template.
const fn needs_branch(&self) -> bool {
self.local.branch
}
}

/// Abstraction over git and working-directory queries for testability.
///
/// Production code uses [`RealGitContext`]; tests substitute a mock.
Expand Down Expand Up @@ -160,73 +224,110 @@ pub fn interpolate_collection_name(
template: &str,
git: &impl GitContext,
) -> Result<String, ConfigError> {
let needs_repo = template.contains("{repo}");
let needs_owner = template.contains("{owner}");
let needs_cwd = template.contains("{cwd}");
let needs_branch = template.contains("{branch}");
let needs = PlaceholderNeeds::from_template(template);

// Fast path: no placeholders at all.
if !needs_repo && !needs_owner && !needs_cwd && !needs_branch {
if needs.is_empty() {
return Ok(template.to_owned());
}

let mut result = template.to_owned();

// Resolve remote-derived placeholders together (one git call).
if needs_repo || needs_owner {
let url = git.remote_url()?.ok_or_else(|| {
let affected = unresolved_remote_placeholders(needs_owner, needs_repo);
ConfigError::UnresolvablePlaceholder {
placeholder: affected,
reason: "No git remote named 'origin' is \
configured"
.to_owned(),
}
})?;
if needs.needs_remote() {
result = replace_remote_placeholders(result, git, &needs)?;
}

let info = parse_remote_url(&url)?;

if needs_owner {
let owner = info
.owner
.ok_or_else(|| ConfigError::UnresolvablePlaceholder {
placeholder: "owner".to_owned(),
reason: format!(
concat!(
"Remote URL '{0}' has a ",
"single-segment path with ",
"no owner component"
),
url
),
})?;
result = result.replace("{owner}", &owner);
}
if needs_repo {
result = result.replace("{repo}", &info.repo);
result = replace_cwd_placeholder(result, git, &needs)?;
result = replace_branch_placeholder(result, git, &needs)?;

Ok(result)
}

/// Replaces placeholders derived from the `origin` remote.
fn replace_remote_placeholders(
mut result: String,
git: &impl GitContext,
needs: &PlaceholderNeeds,
) -> Result<String, ConfigError> {
let url = git.remote_url()?.ok_or_else(|| {
let affected = unresolved_remote_placeholders(needs.needs_owner(), needs.needs_repo());
ConfigError::UnresolvablePlaceholder {
placeholder: affected,
reason: "No git remote named 'origin' is \
configured"
.to_owned(),
}
})?;

let info = parse_remote_url(&url)?;

if needs.needs_owner() {
let owner = remote_owner(&url, info.owner)?;
result = result.replace("{owner}", &owner);
}

if needs.needs_repo() {
result = result.replace("{repo}", &info.repo);
}

if needs_cwd {
Ok(result)
}

/// Returns a remote owner or the domain error for single-segment remotes.
fn remote_owner(url: &str, owner: Option<String>) -> Result<String, ConfigError> {
owner.ok_or_else(|| ConfigError::UnresolvablePlaceholder {
placeholder: "owner".to_owned(),
reason: format!(
concat!(
"Remote URL '{0}' has a ",
"single-segment path with ",
"no owner component"
),
url
),
})
}

/// Replaces the working-directory placeholder when present.
fn replace_cwd_placeholder(
result: String,
git: &impl GitContext,
needs: &PlaceholderNeeds,
) -> Result<String, ConfigError> {
if needs.needs_cwd() {
let basename = git.cwd_basename()?;
result = result.replace("{cwd}", &basename);
Ok(result.replace("{cwd}", &basename))
} else {
Ok(result)
}
}

if needs_branch {
/// Replaces the branch placeholder when present.
fn replace_branch_placeholder(
result: String,
git: &impl GitContext,
needs: &PlaceholderNeeds,
) -> Result<String, ConfigError> {
if needs.needs_branch() {
let branch = git
.branch_name()?
.ok_or_else(|| ConfigError::UnresolvablePlaceholder {
placeholder: "branch".to_owned(),
reason: concat!(
"Not on a named branch ",
"(detached HEAD or not a git repository)"
)
.to_owned(),
})?;
result = result.replace("{branch}", &branch);
.ok_or_else(unresolvable_branch_placeholder)?;
Ok(result.replace("{branch}", &branch))
} else {
Ok(result)
}
}

Ok(result)
/// Builds the domain error for an unavailable branch name.
fn unresolvable_branch_placeholder() -> ConfigError {
ConfigError::UnresolvablePlaceholder {
placeholder: "branch".to_owned(),
reason: concat!(
"Not on a named branch ",
"(detached HEAD or not a git repository)"
)
.to_owned(),
}
}

/// Builds a comma-separated list of remote-derived placeholders that
Expand Down
Loading
Loading