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
22 changes: 20 additions & 2 deletions .github/workflows/clippy_check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ jobs:
clippy_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout main repository
uses: actions/checkout@v4
with:
path: salobj

- name: Checkout public dependency repository
uses: actions/checkout@v4
with:
repository: 'lsst-ts/ts_xml'
path: ts_xml

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy

- name: Run Clippy
run: cargo clippy --all-targets --all-features
working-directory: ./salobj
env:
TS_XML_DIR: ${{ github.workspace }}/ts_xml/python/lsst/ts/xml/data/sal_interfaces
run: cargo clippy --all-targets --all-features
68 changes: 68 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Developer Agent Guidelines - rs_salobj

This repository contains the Rust implementation of the Object-Oriented Service Abstraction Layer (SAL), used for control software at the Vera C. Rubin Observatory.

## 🛠 Build, Lint, and Test Commands

The project is a Rust workspace. Commands should generally be run from the root.

### Build & Check
- **Build all:** `cargo build --workspace`
- **Check (fast):** `cargo check --workspace`
- **Clippy (lint):** `cargo clippy --workspace -- -D warnings`
- **Format:** `cargo fmt --all`

### Running Tests
- **Run all tests:** `cargo test --workspace`
- **Run single crate tests:** `cargo test -p salobj`
- **Run a single test module:** `cargo test -p salobj --lib domain::tests`
- **Run a specific test:** `cargo test -p salobj --lib domain::tests::get_default_identity -- --nocapture`
- **Run documentation tests:** `cargo test --doc`

## 🦀 Code Style & Conventions

### 1. General Principles
- Follow standard Rust idioms and naming conventions (`PascalCase` for types/enums, `snake_case` for functions/variables).
- Use `rustfmt` for all formatting.
- Prefer `async/await` using the `tokio` runtime.

### 2. Imports
- Group imports in the following order:
1. Standard library (`std::...`)
2. External crates
3. Internal crate modules (`crate::...`)
- Use `use crate::...` for internal module imports within the same crate.

### 3. Error Handling
- Use the custom error system defined in `salobj/src/error/errors.rs`.
- All fallible functions should return `SalObjResult<T>`, which is an alias for `Result<T, SalObjError>`.
- Implement `From<ExternalErrorType>` for `SalObjError` in `errors.rs` to support the `?` operator.
- Avoid `unwrap()` and `expect()` in library code; prefer returning errors.

### 4. Async & Concurrency
- Use `tokio` for tasks, timers, and synchronization.
- Use `tokio::sync::mpsc` for multi-producer, single-consumer communication.
- Use `tokio::sync::watch` for state changes that need to be observed by multiple tasks.
- Background tasks should be spawned using `tokio::task::spawn`.

### 5. SAL Specific Patterns
- **Topics:** Use the `#[add_sal_topic_fields]` attribute and `#[derive(BaseSALTopic)]` on structs representing SAL topics.
- **Commands:** Use the `handle_command!` macro in `TestCSC` or similar controllers to implement the command processing loop.
- **CSCs:** Components should implement the `BaseCSC` trait and follow the state machine transitions (`Standby`, `Disabled`, `Enabled`, `Fault`, `Offline`).
- **Domain:** Use the `Domain` struct to manage Kafka client identities and configurations.

### 6. Documentation
- Use `//!` for module-level documentation.
- Use `///` for public struct, enum, and function documentation.
- Document the *why* and any side effects or background tasks spawned by a function.

## 📁 Project Structure

- `salobj/`: Main library implementation.
- `base_topic_derive/`: Procedural macros for deriving `BaseSALTopic`.
- `handle_command/`: Procedural macro for command handling logic.
- `ts_xml_define/`: Procedural macro for generating SAL telemetry Rust structs from XML schema definitions (independent crate).

## ⚠️ Safety & Best Practices
- **Kafka:** Ensure `LSST_KAFKA_BROKER_ADDR` and `LSST_SCHEMA_REGISTRY_URL` are handled via the `Domain` utility.
- **Cleanup:** Always ensure background tasks (like heartbeats or telemetry loops) are properly aborted or joined when a component is dropped or transitions to `Offline`.
45 changes: 38 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ members = [
"salobj",
"base_topic_derive",
"handle_command",
"ts_xml_define",
]
resolver = "2"
141 changes: 69 additions & 72 deletions base_topic_derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,82 +85,79 @@ pub fn add_sal_topic_fields(_args: TokenStream, input: TokenStream) -> TokenStre
let mut ast = parse_macro_input!(input as DeriveInput);
match &mut ast.data {
syn::Data::Struct(ref mut struct_data) => {
match &mut struct_data.fields {
syn::Fields::Named(fields) => {
fields.named.push(
syn::Field::parse_named
.parse2(quote! { private_origin: i32 })
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! { private_identity: String })
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_seqNum")]
private_seq_num: i32
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_rcvStamp")]
private_rcv_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_sndStamp")]
private_snd_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "salIndex", default = "get_default_sal_index")]
sal_index: i32
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_efdStamp")]
private_efd_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_kafkaStamp")]
private_kafka_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_revCode")]
private_rev_code: String
})
.unwrap(),
);
}
_ => (),
if let syn::Fields::Named(fields) = &mut struct_data.fields {
fields.named.push(
syn::Field::parse_named
.parse2(quote! { private_origin: i32 })
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! { private_identity: String })
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_seqNum")]
private_seq_num: i32
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_rcvStamp")]
private_rcv_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_sndStamp")]
private_snd_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "salIndex", default = "get_default_sal_index")]
sal_index: i32
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_efdStamp")]
private_efd_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_kafkaStamp")]
private_kafka_stamp: f64
})
.unwrap(),
);
fields.named.push(
syn::Field::parse_named
.parse2(quote! {
#[serde(rename = "private_revCode")]
private_rev_code: String
})
.unwrap(),
);
}

return quote! {
quote! {
#ast
}
.into();
.into()
}
_ => panic!("`add_sal_topic_fields` has to be used with structs "),
}
Expand Down
1 change: 1 addition & 0 deletions salobj/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ edition = "2021"
apache-avro = "0.19.0"
base_topic_derive = {path = "../base_topic_derive"}
handle_command = {path = "../handle_command"}
ts_xml_define = {path = "../ts_xml_define"}
chrono = "0.4"
clap = { version = "4.1.6", features = ["derive"] }
num-traits = "0.2.19"
Expand Down
7 changes: 3 additions & 4 deletions salobj/src/component_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,11 @@ mod tests {
#[test]
fn create_test_component_info() {
let component_info = ComponentInfo::new("Test", "unit_test").unwrap();
let component_info_commands: Vec<&String> =
component_info.commands.keys().into_iter().collect();
let component_info_commands: Vec<&String> = component_info.commands.keys().collect();

assert_eq!(component_info.name, "Test");
assert_eq!(component_info.topic_subname, "unit_test");
assert_eq!(component_info.is_indexed(), true);
assert!(component_info.is_indexed());
assert_eq!(component_info.ack_cmd.get_topic_name(), "ackcmd");
assert_eq!(component_info.ack_cmd.get_sal_name(), "Test_ackcmd");
assert_eq!(component_info.get_topic_subname(), "unit_test");
Expand Down Expand Up @@ -202,7 +201,7 @@ mod tests {
.collect();

let heartbeat_schema = avro_schema.get("logevent_heartbeat").unwrap();
let heartbeat_record = Record::new(&heartbeat_schema).unwrap();
let heartbeat_record = Record::new(heartbeat_schema).unwrap();

let record_fields: HashSet<String> = heartbeat_record
.fields
Expand Down
Loading