Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ examples/*/reports/
examples/*/test-results/
plugins/*/tests/reports/
plugins/*/tests/test-results/
segkit/reports/
notes/

# AI-generated artifacts (don't commit these)
Expand Down
80 changes: 80 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Contributing

This document defines how we structure commits and pull requests in this repository.
For architecture, plugin conventions, and platform-specific guidance see `CLAUDE.md`,
`plugins/CONVENTIONS.md`, and the `wiki/` docs.

## Commit format

```
<type>(<scope>): <summary> (<epic>)
```

Every commit declares exactly one `<type>`, names the `<scope>` it touches, and references the
`<epic>` (or task) it belongs to. One commit is one kind of change — never mix types.

Example:

```
feat(segkit): add leveled logger with color and file output (MVP-0.1)
```

### Type — exactly one per commit

| Type | Use for |
| ------- | ----------------------------------------------------------------------------- |
| `feat` | New behavior or capability. |
| `bug` | A fix for incorrect behavior. |
| `docs` | Documentation only (Markdown, REFERENCE files, comments, task lists). |
| `tests` | Test code only (new tests, fixtures, harness wiring) with no behavior change. |
| `gen` | Generated or mechanical churn — see below. |

These map onto the conventional-commit types referenced in `CLAUDE.md`: `bug` is `fix`,
`tests` is `test`, and `gen` is new. We do not use `refactor`/`perf`/`chore` as top-level
types — a refactor that changes no behavior is `feat` or `bug` depending on intent, and
mechanical churn is `gen`.

### `gen` — isolate the noise

Generated and mechanical changes go in their own `gen` commits, separated from hand-written
work, so reviewers can skim them with confidence instead of auditing them line by line. This
includes:

- Formatting-only changes (`treefmt`, `cargo fmt`, `gofmt`, prettier).
- Lock files (`Cargo.lock`, `package-lock.json`, `Podfile.lock`, `devbox.lock`, `devices.lock`).
- Scaffolder/codegen output and other regenerated artifacts.

A `gen` commit's summary must say plainly what produced it, e.g.
`gen(segkit): cargo fmt (MVP-0.1)` or `gen(rn): regenerate package-lock after dep bump`.
Never fold a `gen` change into a `feat`/`bug` commit — if a code change forces a reformat or a
lock-file update, split the mechanical part into a trailing `gen` commit.

### Scope

The area of the repo the commit touches: `segkit`, `android`, `ios`, `react-native` (or `rn`),
`ci`, `docs`, `tests`, `examples`. Use the narrowest scope that fits.

### Epic reference

Reference the epic or task somewhere in the title, in parentheses at the end:
`(MVP-0.1)`, `(M1.2)`, `(T3)`. This ties the commit back to `notes/segkit-milestones/`. For
work with no epic, use the issue or PR reference (`(#123)`) or omit the suffix for one-off
fixes.

## Pull requests

- **Keep each commit reviewable: under ~600 lines of diff.** Split larger work into focused
commits along natural seams (module, layer, feature). Big mechanical diffs (lock files,
generated output) don't count against the budget when isolated in their own `gen` commit —
that is the point of isolating them.
- Order commits so the PR reads top-to-bottom: foundational changes first, then the work that
builds on them, with `gen` and `docs` commits grouped rather than scattered.
- A PR should land one epic or one self-contained slice of one. Don't bundle unrelated epics.

## Attribution

Commits created with AI assistance end with a trailer:

```
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
```
47 changes: 47 additions & 0 deletions segkit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# segkit

Segment SDK developer toolkit. A Rust CLI that wraps the Devbox plugin scripts
(`android.sh`, `ios.sh`, `rn.sh`, `metro.sh`) and provides project scaffolding,
configuration, and environment management.

Build and test through Devbox from the repo root:

```bash
devbox run segkit:build # cargo build
devbox run segkit:test # cargo test
devbox run segkit:check # fmt check + clippy + test
```

## Logging

segkit logs at four levels — `debug`, `info`, `warn`, `error` — using the same
`[LEVEL] [segkit] message` prefix as the shell plugins, so interleaved CI output
reads uniformly across Rust and shell.

`debug` is suppressed unless `SEGKIT_DEBUG=1` (or the global `DEBUG=1`), mirroring
the shell `*_DEBUG` flags:

```bash
SEGKIT_DEBUG=1 segkit rn doctor # shows [DEBUG] delegation traces
```

### Output sinks

Logs are diagnostics, not program output, so they go to stderr — leaving stdout
clean for data you might pipe or capture (e.g. `config show`). Every log line is
written to two places:

- **stderr**, with the `[LEVEL]` tag color-coded by level (gray/blue/yellow/red)
when stderr is a terminal. Color is suppressed automatically when output is
piped or redirected, or when [`NO_COLOR`](https://no-color.org/) is set.
- **`${REPORTS_DIR:-reports}/segkit.log`**, appended across runs and never
colored, so it accumulates a historical log that CI can upload as a job
artifact. `REPORTS_DIR` is the same reports root the delegation timing/error
logs use.

The file sink is plain text by construction — ANSI color codes only ever reach a
terminal, never the log file.

Subprocesses segkit spawns (emulator, simulator, the SDK example, process-compose)
inherit segkit's streams: their stdout and stderr pass through unchanged, so their
own diagnostics stay on stderr and their data stays on stdout.
20 changes: 16 additions & 4 deletions segkit/src/config_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;

use crate::init_cmd::{validate_plugin_names, PLUGIN_REGISTRY};
use crate::init_cmd::{PLUGIN_REGISTRY, validate_plugin_names};
use crate::util::log::{err, info};
use crate::util::project::find_file;
use crate::util::xcconfig::XCConfig;
Expand Down Expand Up @@ -50,7 +50,9 @@ fn apply_plugin_changes(

// Start from --plugins (replace) or current value
let mut current = if let Some(list) = plugins {
list.iter().map(|s| s.to_lowercase()).collect::<BTreeSet<String>>()
list.iter()
.map(|s| s.to_lowercase())
.collect::<BTreeSet<String>>()
} else {
let raw = config.get("ENABLED_PLUGINS").unwrap_or_default();
parse_plugin_csv(&raw)
Expand All @@ -77,7 +79,14 @@ pub fn run_show() -> ExitCode {

eprintln!("Config: {}", config_path.display());
eprintln!();
eprintln!(" Write Key: {}", if write_key.is_empty() { "(not set)" } else { &write_key });
eprintln!(
" Write Key: {}",
if write_key.is_empty() {
"(not set)"
} else {
&write_key
}
);
eprintln!();
eprintln!(" Plugins:");
for p in PLUGIN_REGISTRY {
Expand Down Expand Up @@ -113,7 +122,10 @@ pub fn run_set(
}
};
config.set("ENABLED_PLUGINS", &csv);
info(&format!("Enabled plugins: {}", if csv.is_empty() { "(none)" } else { &csv }));
info(&format!(
"Enabled plugins: {}",
if csv.is_empty() { "(none)" } else { &csv }
));
}

if let Err(e) = fs::write(&config_path, config.to_string()) {
Expand Down
4 changes: 4 additions & 0 deletions segkit/src/delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use std::time::Instant;
use anyhow::{Context, Result};
use chrono::Utc;

use crate::util::log;

fn reports_dir() -> String {
std::env::var("REPORTS_DIR").unwrap_or_else(|_| "reports".into())
}
Expand Down Expand Up @@ -89,6 +91,8 @@ pub fn run(script: &str, args: &[String]) -> ExitCode {
}
};

log::debug(&format!("delegating to {script_path} {}", args.join(" ")));

let start = Instant::now();

let status = Command::new(&script_path).args(args).status();
Expand Down
102 changes: 81 additions & 21 deletions segkit/src/init_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ fn generate_deps_yaml(plugins: &[&Plugin]) -> String {
/// Generate the project.yml with dynamic plugin support.
fn generate_project_yml(name: &str, org: &str, plugins: &[&Plugin]) -> String {
let mut packages_section = String::new();
packages_section.push_str(" Segment:\n url: https://github.com/segmentio/analytics-swift\n from: 1.9.3\n");
packages_section.push_str(
" Segment:\n url: https://github.com/segmentio/analytics-swift\n from: 1.9.3\n",
);
packages_section.push_str(&generate_packages_yaml(plugins));

let mut deps_section = String::from(" - package: Segment\n");
Expand Down Expand Up @@ -618,7 +620,11 @@ fn prompt(label: &str, default: &str) -> String {
return default.to_string();
}
let trimmed = line.trim();
if trimmed.is_empty() { default.to_string() } else { trimmed.to_string() }
if trimmed.is_empty() {
default.to_string()
} else {
trimmed.to_string()
}
}

/// Prompt user to select plugins interactively (toggle with numbers, Enter to confirm).
Expand Down Expand Up @@ -659,10 +665,11 @@ fn prompt_plugins(already_selected: &[String]) -> Vec<String> {
"all" => selected.iter_mut().for_each(|s| *s = true),
"none" => selected.iter_mut().for_each(|s| *s = false),
_ => {
if let Ok(n) = token.parse::<usize>() {
if n >= 1 && n <= PLUGIN_REGISTRY.len() {
selected[n - 1] = !selected[n - 1];
}
if let Ok(n) = token.parse::<usize>()
&& n >= 1
&& n <= PLUGIN_REGISTRY.len()
{
selected[n - 1] = !selected[n - 1];
}
}
}
Expand Down Expand Up @@ -715,7 +722,8 @@ pub fn run(
}

let org = org.unwrap_or_else(|| prompt("Organization identifier", "com.example"));
let write_key = write_key.unwrap_or_else(|| prompt("Segment write key", "demo_write_key_not_real"));
let write_key =
write_key.unwrap_or_else(|| prompt("Segment write key", "demo_write_key_not_real"));

let plugin_names = if needs_wizard && plugin_names.is_empty() {
prompt_plugins(&plugin_names)
Expand Down Expand Up @@ -756,10 +764,18 @@ pub fn run(

// project.yml — always includes all 7 plugins as SPM dependencies
let all_plugins: Vec<&Plugin> = PLUGIN_REGISTRY.iter().collect();
write_file(&out, "project.yml", &generate_project_yml(&name, &org, &all_plugins));
write_file(
&out,
"project.yml",
&generate_project_yml(&name, &org, &all_plugins),
);

// devbox.json
write_file(&out, "devbox.json", &apply(DEVBOX_JSON, &name, &org, &write_key, &bundle_id));
write_file(
&out,
"devbox.json",
&apply(DEVBOX_JSON, &name, &org, &write_key, &bundle_id),
);

// Device definitions
write_file(&out, "devbox.d/ios/devices/max.json", DEVICE_MAX_JSON);
Expand All @@ -770,23 +786,67 @@ pub fn run(
// SegmentConfig.conf — runtime config read by Config.swift
// Uses .conf extension so Xcode bundles it as a resource (not a build config)
let enabled_keys: Vec<String> = plugins.iter().map(|p| p.key.to_string()).collect();
write_file(&out, &format!("{src}/SegmentConfig.conf"), &generate_xcconfig(&write_key, &enabled_keys));

write_file(&out, &format!("{src}/Config.swift"), &apply(CONFIG_SWIFT, &name, &org, &write_key, &bundle_id));
write_file(&out, &format!("{src}/{name}App.swift"), &apply(APP_SWIFT, &name, &org, &write_key, &bundle_id));
write_file(&out, &format!("{src}/ContentView.swift"), &generate_content_view(&name));
write_file(&out, &format!("{src}/ConsoleLoggerPlugin.swift"), CONSOLE_LOGGER_SWIFT);
write_file(
&out,
&format!("{src}/SegmentConfig.conf"),
&generate_xcconfig(&write_key, &enabled_keys),
);

write_file(
&out,
&format!("{src}/Config.swift"),
&apply(CONFIG_SWIFT, &name, &org, &write_key, &bundle_id),
);
write_file(
&out,
&format!("{src}/{name}App.swift"),
&apply(APP_SWIFT, &name, &org, &write_key, &bundle_id),
);
write_file(
&out,
&format!("{src}/ContentView.swift"),
&generate_content_view(&name),
);
write_file(
&out,
&format!("{src}/ConsoleLoggerPlugin.swift"),
CONSOLE_LOGGER_SWIFT,
);
write_file(&out, &format!("{src}/IDFAPlugin.swift"), IDFA_PLUGIN_SWIFT);

// Asset catalogs
write_file(&out, &format!("{src}/Assets.xcassets/Contents.json"), ASSETS_CONTENTS);
write_file(&out, &format!("{src}/Assets.xcassets/AccentColor.colorset/Contents.json"), ACCENT_COLOR_CONTENTS);
write_file(&out, &format!("{src}/Assets.xcassets/AppIcon.appiconset/Contents.json"), APP_ICON_CONTENTS);
write_file(
&out,
&format!("{src}/Assets.xcassets/Contents.json"),
ASSETS_CONTENTS,
);
write_file(
&out,
&format!("{src}/Assets.xcassets/AccentColor.colorset/Contents.json"),
ACCENT_COLOR_CONTENTS,
);
write_file(
&out,
&format!("{src}/Assets.xcassets/AppIcon.appiconset/Contents.json"),
APP_ICON_CONTENTS,
);

// Test files
write_file(&out, &format!("{name}Tests/{name}Tests.swift"), &apply(TESTS_SWIFT, &name, &org, &write_key, &bundle_id));
write_file(&out, &format!("{name}UITests/{name}UITests.swift"), &apply(UI_TESTS_SWIFT, &name, &org, &write_key, &bundle_id));
write_file(&out, &format!("{name}UITests/{name}UITestsLaunchTests.swift"), &apply(UI_TESTS_LAUNCH_SWIFT, &name, &org, &write_key, &bundle_id));
write_file(
&out,
&format!("{name}Tests/{name}Tests.swift"),
&apply(TESTS_SWIFT, &name, &org, &write_key, &bundle_id),
);
write_file(
&out,
&format!("{name}UITests/{name}UITests.swift"),
&apply(UI_TESTS_SWIFT, &name, &org, &write_key, &bundle_id),
);
write_file(
&out,
&format!("{name}UITests/{name}UITestsLaunchTests.swift"),
&apply(UI_TESTS_LAUNCH_SWIFT, &name, &org, &write_key, &bundle_id),
);

// .gitignore
write_file(&out, ".gitignore", GITIGNORE);
Expand Down
19 changes: 13 additions & 6 deletions segkit/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,22 @@ fn main() -> ExitCode {
Some(Commands::Metro { args }) => delegate::run("metro.sh", &args),
Some(Commands::Doctor { fix }) => doctor::run(fix),
Some(Commands::Uninstall { all, keep }) => uninstall::run(all, &keep),
Some(Commands::Init { sdk, name, org, write_key, plugins }) => {
init_cmd::run(sdk, name, org, write_key, plugins)
}
Some(Commands::Init {
sdk,
name,
org,
write_key,
plugins,
}) => init_cmd::run(sdk, name, org, write_key, plugins),
Some(Commands::Update) => update::run(),
Some(Commands::Config { action }) => match action {
ConfigAction::Show => config_cmd::run_show(),
ConfigAction::Set { write_key, plugins, add_plugins, remove_plugins } => {
config_cmd::run_set(write_key, plugins, add_plugins, remove_plugins)
}
ConfigAction::Set {
write_key,
plugins,
add_plugins,
remove_plugins,
} => config_cmd::run_set(write_key, plugins, add_plugins, remove_plugins),
},
None => {
println!("segkit {}", env!("CARGO_PKG_VERSION"));
Expand Down
9 changes: 5 additions & 4 deletions segkit/src/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,11 @@ fn prompt_selection(installed: &[&DoctorDep]) -> Vec<String> {
let mut selected = Vec::new();
for part in input.split([',', ' ']) {
let part = part.trim();
if let Ok(n) = part.parse::<usize>() {
if n >= 1 && n <= installed.len() {
selected.push(installed[n - 1].name.to_string());
}
if let Ok(n) = part.parse::<usize>()
&& n >= 1
&& n <= installed.len()
{
selected.push(installed[n - 1].name.to_string());
}
}
selected
Expand Down
Loading