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
5 changes: 4 additions & 1 deletion .bin/update_version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ fi

echo "$PREV_VERSION -> $VERSION (${PREV_VERSION//-/--} -> ${VERSION//-/--})"

sed -e "s#Version-${PREV_VERSION//-/--}-information#Version-${VERSION//-/--}-information#g" -e "s#tag/v${PREV_VERSION}#tag/v${VERSION}#g" README.md > a; mv a README.md
sed -e "s#Version-v${PREV_VERSION//-/--}-information#Version-v${VERSION//-/--}-information#g" \
-e "s#tag/v${PREV_VERSION}#tag/v${VERSION}#g" \
-e "s#crates.io-v${PREV_VERSION}#crates.io-v${VERSION}#g" \
README.md > a; mv a README.md
sed -e "s#version = \".*\"#version = \"${VERSION}\"#g" docs/config.toml > a ; mv a docs/config.toml
sed "s/^version = \".*\"/version = \"${VERSION}\"/g" Cargo.toml > a && mv a Cargo.toml

Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,23 @@
asset_name: ${{ matrix.asset_name }}.tar.gz
asset_content_type: application/x-gzip
upload_url: ${{ needs.documents.outputs.upload_url }}

publish_to_crates_io:
runs-on: ubuntu-latest
needs: publish
outputs:
appname: ${{ needs.publish.outputs.appname }}
tag: ${{ needs.publish.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Authenticate with custom registry
id: auth
uses: rust-lang/crates-io-auth-action@v1

- name: publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run:
cargo publish --package sibling
Comment on lines +159 to +176

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}

Copilot Autofix

AI 5 months ago

In general, the fix is to add an explicit permissions block so the GITHUB_TOKEN is scoped to the least privileges required. You can add it at the top level (applying to all jobs) or per‑job. Since all jobs here are part of the same release/publish pipeline and at least the setup, documents and publish jobs clearly need to write releases (which uses contents: write), the simplest safe change is to define a workflow‑level permissions block with contents: write. That both satisfies CodeQL (permissions are now explicit) and matches the actual needs of the workflow.

The single best way to fix this without changing behavior is:

  • Add a root‑level permissions block right after the name: Publish (or just after on:) in .github/workflows/publish.yaml.
  • Set contents: write so the jobs that create and upload releases continue to function.
  • Optionally, you could further restrict other scopes (e.g., leave all others at their implicit none), but we won’t guess additional scopes: contents: write is clearly needed and sufficient for interacting with releases.

Concretely, edit .github/workflows/publish.yaml near the top, add:

permissions:
  contents: write

leaving all jobs and steps unchanged.

Suggested changeset 1
.github/workflows/publish.yaml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -6,6 +6,9 @@
       - main
     types: [closed]
 
+permissions:
+  contents: write
+
 jobs:
   setup:
     runs-on: ubuntu-latest
EOF
@@ -6,6 +6,9 @@
- main
types: [closed]

permissions:
contents: write

jobs:
setup:
runs-on: ubuntu-latest
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +175 to +176

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

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

The run field should use the pipe (|) operator for multi-line commands, or have the command on the same line. As written, this will cause a YAML parsing error. Change to either run: cargo publish --package sibling (single line) or use a pipe operator if multi-line commands are intended.

Suggested change
run:
cargo publish --package sibling
run: cargo publish --package sibling

Copilot uses AI. Check for mistakes.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ members = [ "lib", "cli" ]
resolver = "3"

[workspace.package]
version = "2.0.3"
version = "2.0.4"
repository = "https://github.com/tamada/sibling"
homepage = "https://tamada.github.io/sibling"
readme = "README.md"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

[![Rust Report Card](https://rust-reportcard.xuri.me/badge/github.com/tamada/sibling)](https://rust-reportcard.xuri.me/report/github.com/tamada/sibling)

[![crates.io](https://img.shields.io/badge/crates.io-v2.0.4-orange.svg?logo=rust)](https://crates.io/crates/sibling)
[![License](https://img.shields.io/badge/License-WTFPL-information.svg)](https://github.com/tamada/sibling/blob/master/LICENSE)
[![Version](https://img.shields.io/badge/Version-2.0.3-information.svg)](https://github.com/tamada/sibling/releases/tag/v2.0.3)
[![Version](https://img.shields.io/badge/Version-v2.0.4-information.svg)](https://github.com/tamada/sibling/releases/tag/v2.0.4)

get the next/previous sibling directory name.

Expand Down
28 changes: 14 additions & 14 deletions cli/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::path::PathBuf;

use clap::Parser;
use clap::{Parser, ValueEnum};

use crate::LogLevel;

Expand Down Expand Up @@ -77,15 +77,23 @@ pub(crate) struct CompletionOpts {
pub(crate) dest: PathBuf,
}

#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
pub enum Format {
Json,
Csv,
List,
Default,
}

#[derive(Debug, Parser)]
pub(crate) struct PrintingOpts {
#[arg(
long,
help = "print the result in the csv format",
default_value_t = false,
hide = true
short, long,
help = "print the result in the specified format",
default_value_t = Format::Default,
value_enum,
)]
pub csv: bool,
pub format: Format,

#[arg(
short,
Expand All @@ -95,14 +103,6 @@ pub(crate) struct PrintingOpts {
)]
pub absolute: bool,

#[arg(
short,
long,
help = "list the sibling directories",
default_value_t = false
)]
pub list: bool,

#[arg(
short,
long,
Expand Down
49 changes: 11 additions & 38 deletions cli/src/gencomp.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use std::path::Path;

#[cfg(debug_assertions)]
mod generator {
use clap::{Command, CommandFactory};
use clap_complete::Shell;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::path::Path;

#[cfg(debug_assertions)]
fn generate_impl(s: Shell, app: &mut Command, appname: &str, outdir: &Path, file: String) {
Expand All @@ -14,52 +16,23 @@ mod generator {
}
}

pub(super) fn generate(outdir: PathBuf) {
pub(super) fn generate(outdir: &Path) {
use Shell::{Bash, Elvish, Fish, PowerShell, Zsh};
let appname = "sibling";

let mut app = crate::cli::CliOpts::command();
app.set_bin_name(appname);

generate_impl(
Shell::Bash,
&mut app,
appname,
&outdir,
format!("bash/{appname}"),
);
generate_impl(
Shell::Elvish,
&mut app,
appname,
&outdir,
format!("elvish/{appname}"),
);
generate_impl(
Shell::Fish,
&mut app,
appname,
&outdir,
format!("fish/{appname}"),
);
generate_impl(
Shell::PowerShell,
&mut app,
appname,
&outdir,
format!("powershell/{appname}"),
);
generate_impl(
Shell::Zsh,
&mut app,
appname,
&outdir,
format!("zsh/_{appname}"),
);
generate_impl(Bash, &mut app, appname, outdir, format!("bash/{appname}"));
generate_impl(Elvish, &mut app, appname, outdir, format!("elvish/{appname}"));
generate_impl(Fish, &mut app, appname, outdir, format!("fish/{appname}"));
generate_impl(PowerShell, &mut app, appname, outdir, format!("powershell/{appname}"));
generate_impl(Zsh, &mut app, appname, outdir, format!("zsh/_{appname}"));
}
}

#[allow(dead_code, unused_variables)]
pub(crate) fn generate(outdir: std::path::PathBuf) {
pub(crate) fn generate(outdir: &Path) {
#[cfg(debug_assertions)]
generator::generate(outdir);
}
5 changes: 2 additions & 3 deletions cli/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ use sibling::{Error, Result};
#[folder = "../assets/init"]
struct Assets;

pub(crate) fn generate_init_script(shell_name: String) -> Result<String> {
pub(crate) fn generate_init_script(shell_name: &str) -> Result<String> {
let script_file = match shell_name.to_lowercase().as_str() {
"bash" => "init.bash",
"zsh" => "init.bash",
"bash" | "zsh" => "init.bash",
_ => return Err(Error::Fatal(format!("{shell_name}: Unsupported shell"))),
};
match Assets::get(script_file) {
Expand Down
22 changes: 11 additions & 11 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,37 +19,37 @@ pub enum LogLevel {
}

fn init_log(level: &LogLevel) {
use LogLevel::*;
use LogLevel::{Error, Warn, Info, Debug, Trace};
if std::env::var_os("RUST_LOG").is_none() {
match level {
Error => std::env::set_var("RUST_LOG", "error"),
Warn => std::env::set_var("RUST_LOG", "warn"),
Info => std::env::set_var("RUST_LOG", "info"),
Debug => std::env::set_var("RUST_LOG", "debug"),
Trace => std::env::set_var("RUST_LOG", "trace"),
};
}
}
env_logger::init();
log::info!("Log level set to {level:?}");
}

fn perform_impl(
dirs: Dirs,
dirs: &Dirs,
nexter: &dyn Nexter,
step: usize,
opts: &PrintingOpts,
) -> Result<String> {
) -> String {
let next = dirs.next_with(nexter, step);
printer::result_string(&dirs, next, opts)
printer::result_string(dirs, next, opts)
}

fn perform_from_file(opts: CliOpts) -> Vec<Result<String>> {
let nexter = sibling::NexterFactory::build(opts.nexter);
let nexter = sibling::NexterFactory::create(opts.nexter);
let r = match opts.input {
None => Err(Error::Fatal("input is not specified".into())),
Some(file) => match Dirs::new_from_file(file) {
Err(e) => Err(e),
Ok(dirs) => perform_impl(dirs, nexter.as_ref(), opts.step, &opts.p_opts),
Ok(dirs) => Ok(perform_impl(&dirs, nexter.as_ref(), opts.step, &opts.p_opts)),
},
};
vec![r]
Expand All @@ -63,12 +63,12 @@ fn perform_each(
) -> Result<String> {
match Dirs::new(dir) {
Err(e) => Err(e),
Ok(dirs) => perform_impl(dirs, nexter, step, opts),
Ok(dirs) => Ok(perform_impl(&dirs, nexter, step, opts)),
}
}

fn perform_sibling(opts: CliOpts) -> Vec<Result<String>> {
let nexter = sibling::NexterFactory::build(opts.nexter);
let nexter = sibling::NexterFactory::create(opts.nexter);
let target_dirs = if opts.dirs.is_empty() {
vec![std::env::current_dir().unwrap()]
} else {
Expand All @@ -89,7 +89,7 @@ fn perform_sibling(opts: CliOpts) -> Vec<Result<String>> {

fn perform(opts: CliOpts) -> Vec<Result<String>> {
if let Some(shell) = opts.init {
vec![init::generate_init_script(shell)]
vec![init::generate_init_script(&shell)]
} else if opts.input.is_some() {
perform_from_file(opts)
} else {
Expand All @@ -109,7 +109,7 @@ fn main() {
if cfg!(debug_assertions) {
#[cfg(debug_assertions)]
if opts.compopts.completion {
return gencomp::generate(opts.compopts.dest);
return gencomp::generate(&opts.compopts.dest);
}
}
for item in perform(opts) {
Expand Down
76 changes: 46 additions & 30 deletions cli/src/printer.rs
Original file line number Diff line number Diff line change
@@ -1,50 +1,66 @@
use std::path::Path;

use crate::cli::PrintingOpts;
use sibling::{Dir, Dirs, Result};
use crate::cli::{Format, PrintingOpts};
use sibling::{Dir, Dirs};

pub(crate) fn result_string(
dirs: &Dirs,
next: Option<Dir<'_>>,
opts: &PrintingOpts,
) -> Result<String> {
if opts.csv {
csv_string(dirs, next, opts.absolute)
} else if opts.list {
list_string(dirs, next, opts)
} else if next.is_none() {
no_more_dir_string(dirs, opts)
} else {
result_string_impl(dirs, next, opts)
) -> String {
match opts.format {
Format::Json => json_string(dirs, next, opts.absolute),
Format::Csv => csv_string(dirs, next, opts.absolute),
Format::List => list_string(dirs, next.as_ref(), opts),
Format::Default => {
if next.is_none() {
no_more_dir_string(dirs, opts)
} else {
result_string_impl(dirs, next, opts)
}
},
}
}

fn csv_string(dirs: &Dirs, next: Option<Dir<'_>>, absolute: bool) -> Result<String> {
fn json_string(dirs: &Dirs, next: Option<Dir<'_>>, absolute: bool) -> String {
let current = dirs.current();
let next_path = next.as_ref().map(|n| pathbuf_to_string(Some(n.path()), absolute));
format!(
r#"{{"current":{{"path":"{}","index":{}}},"next":{{"path":"{}","index":{}}},"total":{}}}"#,
pathbuf_to_string(Some(dirs.current().path()), absolute),
current.index() + 1,
next_path.unwrap_or_default(),
next.map_or(-1, |n| i32::try_from(n.index()).unwrap() + 1),

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

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

The conversion from usize to i32 can panic if the index exceeds i32::MAX. While unlikely in practice, if the system has extremely large directory lists, this could cause a panic. Consider handling this error gracefully or documenting the assumption that directory indices will never exceed i32::MAX.

Copilot uses AI. Check for mistakes.
dirs.len()
)
}
Comment on lines +25 to +36

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

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

The new JSON format functionality lacks test coverage. Since the library has comprehensive test coverage for other functions (as seen in lib/src/lib.rs), it would be consistent to add tests for this new format option to ensure the JSON output is correctly structured and handles edge cases (e.g., empty directories, no next directory).

Copilot uses AI. Check for mistakes.

fn csv_string(dirs: &Dirs, next: Option<Dir<'_>>, absolute: bool) -> String {
let current = dirs.current();
Ok(format!(
r##""{}","{}",{},{},{}"##,
format!(
r#""{}","{}",{},{},{}"#,
pathbuf_to_string(Some(dirs.current().path()), absolute),
pathbuf_to_string(next.as_ref().map(|p| p.path()), absolute),
pathbuf_to_string(next.as_ref().map(Dir::path), absolute),
current.index() + 1,
next.map(|n| n.index() as i32 + 1).unwrap_or(-1),
next.map_or(-1, |n| i32::try_from(n.index()).unwrap() + 1),

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

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

The conversion from usize to i32 can panic if the index exceeds i32::MAX. While unlikely in practice, if the system has extremely large directory lists, this could cause a panic. Consider handling this error gracefully or documenting the assumption that directory indices will never exceed i32::MAX.

Copilot uses AI. Check for mistakes.
dirs.len()
))
)
}

fn no_more_dir_string(dirs: &Dirs, opts: &PrintingOpts) -> Result<String> {
fn no_more_dir_string(dirs: &Dirs, opts: &PrintingOpts) -> String {
if opts.parent {
Ok(pathbuf_to_string(Some(dirs.parent()), opts.absolute))
pathbuf_to_string(Some(dirs.parent()), opts.absolute)
} else {
Ok(String::from("no more sibling directory"))
String::from("no more sibling directory")
}
}

fn list_string(dirs: &Dirs, next: Option<Dir<'_>>, opts: &PrintingOpts) -> Result<String> {
fn list_string(dirs: &Dirs, next: Option<&Dir<'_>>, opts: &PrintingOpts) -> String {
let mut result = vec![];
let current = dirs.current();
let next_index = next.clone().map(|n| n.index() as i32).unwrap_or(-1);
let next_index = next.map_or(-1, |n| i32::try_from(n.index()).unwrap());

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

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

The conversion from usize to i32 can panic if the index exceeds i32::MAX. While unlikely in practice, if the system has extremely large directory lists, this could cause a panic. Consider handling this error gracefully or documenting the assumption that directory indices will never exceed i32::MAX.

Copilot uses AI. Check for mistakes.
for (i, dir) in dirs.directories().enumerate() {
let prefix = if i as i32 == next_index {
let prefix = if i32::try_from(i) == Ok(next_index) {
"> "
} else if i == current.index() {
"* "
Expand All @@ -58,21 +74,21 @@ fn list_string(dirs: &Dirs, next: Option<Dir<'_>>, opts: &PrintingOpts) -> Resul
pathbuf_to_string(Some(dir), opts.absolute)
));
}
Ok(result.join("\n"))
result.join("\n")
}

fn result_string_impl(dirs: &Dirs, next: Option<Dir<'_>>, opts: &PrintingOpts) -> Result<String> {
fn result_string_impl(dirs: &Dirs, next: Option<Dir<'_>>, opts: &PrintingOpts) -> String {
let r = if opts.progress {
format!(
"{} ({}/{})",
pathbuf_to_string(next.as_ref().map(|n| n.path()), opts.absolute),
next.map(|n| n.index() as i32).unwrap_or(-1) + 1,
pathbuf_to_string(next.as_ref().map(Dir::path), opts.absolute),
next.map_or(-1, |n| i32::try_from(n.index()).unwrap()) + 1,

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

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

The conversion from usize to i32 can panic if the index exceeds i32::MAX. While unlikely in practice, if the system has extremely large directory lists, this could cause a panic. Consider handling this error gracefully or documenting the assumption that directory indices will never exceed i32::MAX.

Copilot uses AI. Check for mistakes.
dirs.len()
)
} else {
pathbuf_to_string(next.as_ref().map(|n| n.path()), opts.absolute).to_string()
pathbuf_to_string(next.as_ref().map(Dir::path), opts.absolute).to_string()
};
Ok(r)
r
}

fn pathbuf_to_string(path: Option<&Path>, absolute: bool) -> String {
Expand All @@ -87,6 +103,6 @@ fn pathbuf_to_string(path: Option<&Path>, absolute: bool) -> String {
p.to_string_lossy().to_string()
}
}
None => "".to_string(),
None => String::new(),
}
}
Loading
Loading