@@ -83,10 +83,56 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
8383#[ cfg( test) ]
8484mod tests {
8585 use crate :: json_rpc:: { extract_remote_json_rpc_response, wait_for_remote_json_rpc_response} ;
86- use crate :: outcome:: { parse_outcome, send_op_async, OpRequest } ;
86+ use crate :: outcome:: { parse_outcome, platformio_src_dir_from_env , send_op_async, OpRequest } ;
8787 use crate :: PYTHON_MODULE_VERSION ;
88+ use std:: sync:: Mutex ;
8889 use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
8990
91+ /// Serializes tests that mutate `PLATFORMIO_SRC_DIR`.
92+ ///
93+ /// `std::env::set_var` and `remove_var` mutate process-global state, so
94+ /// running env-var tests in parallel (cargo's default) creates races
95+ /// where one test sees another's value and the assertions flake. A
96+ /// single `Mutex` held across set → call → assert → restore keeps the
97+ /// env-mutating tests strictly serial without forcing the whole crate
98+ /// onto `--test-threads=1`.
99+ static PLATFORMIO_SRC_DIR_LOCK : Mutex < ( ) > = Mutex :: new ( ( ) ) ;
100+
101+ /// RAII guard that restores `PLATFORMIO_SRC_DIR` on drop.
102+ ///
103+ /// Holds the env-var lock for its lifetime so concurrent env-var tests
104+ /// queue rather than race. The previous value is restored exactly as
105+ /// observed (including "unset") so tests don't leak state into siblings
106+ /// that run after them.
107+ struct PlatformioSrcDirGuard {
108+ _lock : std:: sync:: MutexGuard < ' static , ( ) > ,
109+ previous : Option < String > ,
110+ }
111+
112+ impl PlatformioSrcDirGuard {
113+ fn acquire ( ) -> Self {
114+ // PoisonError is fine: the guard exists purely to serialize
115+ // env-var access, and a poisoned mutex still serializes.
116+ let lock = PLATFORMIO_SRC_DIR_LOCK
117+ . lock ( )
118+ . unwrap_or_else ( |e| e. into_inner ( ) ) ;
119+ let previous = std:: env:: var ( "PLATFORMIO_SRC_DIR" ) . ok ( ) ;
120+ Self {
121+ _lock : lock,
122+ previous,
123+ }
124+ }
125+ }
126+
127+ impl Drop for PlatformioSrcDirGuard {
128+ fn drop ( & mut self ) {
129+ match & self . previous {
130+ Some ( v) => std:: env:: set_var ( "PLATFORMIO_SRC_DIR" , v) ,
131+ None => std:: env:: remove_var ( "PLATFORMIO_SRC_DIR" ) ,
132+ }
133+ }
134+ }
135+
90136 /// `parse_outcome` must faithfully extract every field the daemon's
91137 /// `OperationResponse` populates so Python callers can branch on the
92138 /// specific failure mode (see FastLED/fbuild#18). If any field is
@@ -226,6 +272,7 @@ mod tests {
226272 monitor_after : false ,
227273 skip_build : false ,
228274 baud_rate : None ,
275+ src_dir : None ,
229276 }
230277 }
231278
@@ -318,4 +365,185 @@ mod tests {
318365 ) ;
319366 } ) ;
320367 }
368+
369+ /// When `PLATFORMIO_SRC_DIR` is set, the helper must return its value
370+ /// verbatim. This is the env-read primitive both DaemonConnection
371+ /// surfaces use to populate `OpRequest.src_dir`, so FastLED's
372+ /// autoresearch override survives the Python -> daemon hop. See
373+ /// FastLED/fbuild#274.
374+ #[ test]
375+ fn platformio_src_dir_helper_returns_value_when_set ( ) {
376+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
377+ std:: env:: set_var ( "PLATFORMIO_SRC_DIR" , "examples/AutoResearch" ) ;
378+ assert_eq ! (
379+ platformio_src_dir_from_env( ) . as_deref( ) ,
380+ Some ( "examples/AutoResearch" )
381+ ) ;
382+ }
383+
384+ /// When `PLATFORMIO_SRC_DIR` is unset, the helper must return `None`
385+ /// so `OpRequest.src_dir` stays `None` and the daemon falls back to
386+ /// `platformio.ini`'s configured `src_dir`. Mirrors the CLI's
387+ /// `.ok().filter(|s| !s.is_empty())` contract.
388+ #[ test]
389+ fn platformio_src_dir_helper_returns_none_when_unset ( ) {
390+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
391+ std:: env:: remove_var ( "PLATFORMIO_SRC_DIR" ) ;
392+ assert_eq ! ( platformio_src_dir_from_env( ) , None ) ;
393+ }
394+
395+ /// An empty `PLATFORMIO_SRC_DIR` (`""`) must be treated as unset, not
396+ /// forwarded as an empty string. The CLI uses the same `filter(|s|
397+ /// !s.is_empty())` rule and a stray empty value would tell the daemon
398+ /// to compile an empty directory.
399+ #[ test]
400+ fn platformio_src_dir_helper_returns_none_when_empty ( ) {
401+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
402+ std:: env:: set_var ( "PLATFORMIO_SRC_DIR" , "" ) ;
403+ assert_eq ! ( platformio_src_dir_from_env( ) , None ) ;
404+ }
405+
406+ /// `DaemonConnection::build_request` must forward `PLATFORMIO_SRC_DIR`
407+ /// into `OpRequest.src_dir` so the daemon receives the override the
408+ /// caller set on the parent env, matching `fbuild-cli`'s `Build`
409+ /// request construction. Regression guard for FastLED/fbuild#274.
410+ #[ test]
411+ fn daemon_connection_build_request_forwards_platformio_src_dir ( ) {
412+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
413+ std:: env:: set_var ( "PLATFORMIO_SRC_DIR" , "examples/AutoResearch" ) ;
414+ let conn = crate :: daemon_connection:: DaemonConnection :: new (
415+ "tests/platform/uno" . into ( ) ,
416+ "uno" . into ( ) ,
417+ ) ;
418+ let req = conn. build_request ( false , false ) ;
419+ assert_eq ! ( req. src_dir. as_deref( ) , Some ( "examples/AutoResearch" ) ) ;
420+ }
421+
422+ /// `DaemonConnection::deploy_request` must forward `PLATFORMIO_SRC_DIR`
423+ /// for the same reason `build_request` does — the issue's "Done"
424+ /// criteria explicitly call out deploy parity with the CLI.
425+ #[ test]
426+ fn daemon_connection_deploy_request_forwards_platformio_src_dir ( ) {
427+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
428+ std:: env:: set_var ( "PLATFORMIO_SRC_DIR" , "examples/AutoResearch" ) ;
429+ let conn = crate :: daemon_connection:: DaemonConnection :: new (
430+ "tests/platform/uno" . into ( ) ,
431+ "uno" . into ( ) ,
432+ ) ;
433+ let req = conn. deploy_request ( None , false , false , false ) ;
434+ assert_eq ! ( req. src_dir. as_deref( ) , Some ( "examples/AutoResearch" ) ) ;
435+ }
436+
437+ /// When the env var is unset, `build_request` must leave `src_dir` as
438+ /// `None`. Omitting the field on the wire is what lets the daemon fall
439+ /// back to `platformio.ini`'s `src_dir`; a forwarded `Some("")` would
440+ /// break that fallback.
441+ #[ test]
442+ fn daemon_connection_build_request_omits_src_dir_when_env_unset ( ) {
443+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
444+ std:: env:: remove_var ( "PLATFORMIO_SRC_DIR" ) ;
445+ let conn = crate :: daemon_connection:: DaemonConnection :: new (
446+ "tests/platform/uno" . into ( ) ,
447+ "uno" . into ( ) ,
448+ ) ;
449+ let req = conn. build_request ( false , false ) ;
450+ assert ! ( req. src_dir. is_none( ) ) ;
451+ }
452+
453+ /// Same omission guarantee for deploy. The CLI and Python paths must
454+ /// behave identically when the caller has not set
455+ /// `PLATFORMIO_SRC_DIR`.
456+ #[ test]
457+ fn daemon_connection_deploy_request_omits_src_dir_when_env_unset ( ) {
458+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
459+ std:: env:: remove_var ( "PLATFORMIO_SRC_DIR" ) ;
460+ let conn = crate :: daemon_connection:: DaemonConnection :: new (
461+ "tests/platform/uno" . into ( ) ,
462+ "uno" . into ( ) ,
463+ ) ;
464+ let req = conn. deploy_request ( None , false , false , false ) ;
465+ assert ! ( req. src_dir. is_none( ) ) ;
466+ }
467+
468+ /// Async parity with the sync `build_request` forwarding check. The
469+ /// AsyncDaemonConnection is what FastLED uses under asyncio, so a
470+ /// regression here would surface the same wrong-sketch failure mode
471+ /// even after the sync path is fixed.
472+ #[ test]
473+ fn async_daemon_connection_build_request_forwards_platformio_src_dir ( ) {
474+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
475+ std:: env:: set_var ( "PLATFORMIO_SRC_DIR" , "examples/AutoResearch" ) ;
476+ let conn = crate :: async_daemon_connection:: AsyncDaemonConnection :: new (
477+ "tests/platform/uno" . into ( ) ,
478+ "uno" . into ( ) ,
479+ ) ;
480+ let req = conn. build_request ( false , false ) ;
481+ assert_eq ! ( req. src_dir. as_deref( ) , Some ( "examples/AutoResearch" ) ) ;
482+ }
483+
484+ /// Async parity with the sync `deploy_request` forwarding check.
485+ #[ test]
486+ fn async_daemon_connection_deploy_request_forwards_platformio_src_dir ( ) {
487+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
488+ std:: env:: set_var ( "PLATFORMIO_SRC_DIR" , "examples/AutoResearch" ) ;
489+ let conn = crate :: async_daemon_connection:: AsyncDaemonConnection :: new (
490+ "tests/platform/uno" . into ( ) ,
491+ "uno" . into ( ) ,
492+ ) ;
493+ let req = conn. deploy_request ( None , false , false , false ) ;
494+ assert_eq ! ( req. src_dir. as_deref( ) , Some ( "examples/AutoResearch" ) ) ;
495+ }
496+
497+ /// Async omission parity: with the env var unset, the async
498+ /// surface must also leave `src_dir` as `None` so the daemon's
499+ /// `platformio.ini` fallback still kicks in.
500+ #[ test]
501+ fn async_daemon_connection_build_request_omits_src_dir_when_env_unset ( ) {
502+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
503+ std:: env:: remove_var ( "PLATFORMIO_SRC_DIR" ) ;
504+ let conn = crate :: async_daemon_connection:: AsyncDaemonConnection :: new (
505+ "tests/platform/uno" . into ( ) ,
506+ "uno" . into ( ) ,
507+ ) ;
508+ let req = conn. build_request ( false , false ) ;
509+ assert ! ( req. src_dir. is_none( ) ) ;
510+ }
511+
512+ /// Async omission parity for deploy.
513+ #[ test]
514+ fn async_daemon_connection_deploy_request_omits_src_dir_when_env_unset ( ) {
515+ let _guard = PlatformioSrcDirGuard :: acquire ( ) ;
516+ std:: env:: remove_var ( "PLATFORMIO_SRC_DIR" ) ;
517+ let conn = crate :: async_daemon_connection:: AsyncDaemonConnection :: new (
518+ "tests/platform/uno" . into ( ) ,
519+ "uno" . into ( ) ,
520+ ) ;
521+ let req = conn. deploy_request ( None , false , false , false ) ;
522+ assert ! ( req. src_dir. is_none( ) ) ;
523+ }
524+
525+ /// `OpRequest` serializes `src_dir` with `skip_serializing_if =
526+ /// "Option::is_none"`, so when the env var is unset the field must not
527+ /// appear in the JSON sent to the daemon. The daemon's
528+ /// `BuildRequest.src_dir` is `Option<String>` with `serde(default)`;
529+ /// omitting the field is the only way to get the platformio.ini
530+ /// fallback. A forwarded `null` would be equivalent here, but
531+ /// historical CLI traffic doesn't include the key at all so we keep
532+ /// parity.
533+ #[ test]
534+ fn op_request_serializes_src_dir_only_when_set ( ) {
535+ let mut req = sample_op_request ( ) ;
536+ let json = serde_json:: to_string ( & req) . unwrap ( ) ;
537+ assert ! (
538+ !json. contains( "src_dir" ) ,
539+ "src_dir must be omitted when None, got {json}"
540+ ) ;
541+
542+ req. src_dir = Some ( "examples/AutoResearch" . into ( ) ) ;
543+ let json = serde_json:: to_string ( & req) . unwrap ( ) ;
544+ assert ! (
545+ json. contains( r#""src_dir":"examples/AutoResearch""# ) ,
546+ "src_dir must serialize verbatim when set, got {json}"
547+ ) ;
548+ }
321549}
0 commit comments